diff --git a/antora-playbook.yml b/antora-playbook.yml index 1ff597c1d2..94ce607ae1 100644 --- a/antora-playbook.yml +++ b/antora-playbook.yml @@ -8,7 +8,7 @@ content: branches: HEAD start_path: ./ - url: https://github.com/tinymce/tinymce-docs.git - branches: [ tinymce/5, tinymce/6, tinymce/7] + branches: [ tinymce/5, tinymce/6, tinymce/7 ] urls: html_extension_style: indexify latest_version_segment: latest diff --git a/antora.yml b/antora.yml index 19391046b8..1c000e450b 100644 --- a/antora.yml +++ b/antora.yml @@ -10,7 +10,7 @@ asciidoc: companyurl: https://www.tiny.cloud cdnurl: https://cdn.tiny.cloud/1/no-api-key/tinymce/7/tinymce.min.js tdcdnurl: https://cdn.tiny.cloud/1/_your_api_key_/tinydrive/7/tinydrive.min.js - tinymce_live_demo_url: https://cdn.tiny.cloud/1/qagffr3pkuv17a8on1afax661irst1hbr4e6tbv888sz91jc/tinymce/7/tinymce.min.js + tinymce_live_demo_url: https://cdn.tiny.cloud/1/qagffr3pkuv17a8on1afax661irst1hbr4e6tbv888sz91jc/tinymce/8-dev/tinymce.min.js tinydrive_live_demo_url: https://cdn.tiny.cloud/1/qagffr3pkuv17a8on1afax661irst1hbr4e6tbv888sz91jc/tinydrive/7/tinydrive.min.js webcomponent_url: https://cdn.jsdelivr.net/npm/@tinymce/tinymce-webcomponent/dist/tinymce-webcomponent.min.js jquery_url: https://cdn.jsdelivr.net/npm/@tinymce/tinymce-jquery@2/dist/tinymce-jquery.min.js diff --git a/modules/ROOT/examples/live-demos/comments-callback-with-mentions/index.js b/modules/ROOT/examples/live-demos/comments-callback-with-mentions/index.js index bb74433204..8cd150d15d 100644 --- a/modules/ROOT/examples/live-demos/comments-callback-with-mentions/index.js +++ b/modules/ROOT/examples/live-demos/comments-callback-with-mentions/index.js @@ -1,18 +1,23 @@ import ('https://cdn.jsdelivr.net/npm/@faker-js/faker@9/dist/index.min.js').then(({ faker }) => { + /* This represents a database of users on the server */ const userDb = { 'michaelcook': { id: 'michaelcook', name: 'Michael Cook', - fullName: 'Michael Cook', - description: 'Product Owner', - image: "{{imagesdir}}/avatars/michaelcook.png" + avatar: '{{imagesdir}}/avatars/michaelcook.png', + custom: { + fullName: 'Michael Cook', + description: 'Product Owner' + } }, 'kalebwilson': { id: 'kalebwilson', name: 'Kaleb Wilson', - fullName: 'Kaleb Wilson', - description: 'Marketing Director', - image: "{{imagesdir}}/avatars/kalebwilson.png" + avatar: '{{imagesdir}}/avatars/kalebwilson.png', + custom: { + fullName: 'Kaleb Wilson', + description: 'Marketing Director', + } } }; @@ -35,7 +40,7 @@ import ('https://cdn.jsdelivr.net/npm/@faker-js/faker@9/dist/index.min.js').then const getAuthorInfo = (uid) => { const user = userDb[uid]; if (user) { - return fillAuthorInfo(user.id, user.fullName, user.image); + return fillAuthorInfo(user.id, user.custom.fullName, user.avatar); } return { author: uid, @@ -85,8 +90,8 @@ import ('https://cdn.jsdelivr.net/npm/@faker-js/faker@9/dist/index.min.js').then const resolvedConversationDb = {}; const setupFakeServer = () => { - const images = [ adminUser.image, currentUser.image ]; - const userNames = [ adminUser.fullName, currentUser.fullName ]; + const images = [ adminUser.avatar, currentUser.avatar ]; + const userNames = [ adminUser.custom.fullName, currentUser.custom.fullName ]; for (let i = 0; i < numberOfUsers; i++) { images.push(faker.image.avatar()); @@ -99,14 +104,16 @@ import ('https://cdn.jsdelivr.net/npm/@faker-js/faker@9/dist/index.min.js').then [currentUser.id]: currentUser }; userNames.map((fullName) => { - if ((fullName !== currentUser.fullName) && (fullName !== adminUser.fullName)) { + if ((fullName !== currentUser.custom.fullName) && (fullName !== adminUser.custom.fullName)) { const id = fullName.toLowerCase().replace(/ /g, ''); userDb[id] = { id, name: fullName, - fullName, - description: faker.person.jobTitle(), - image: images[Math.floor(images.length * Math.random())] + avatar: images[Math.floor(images.length * Math.random())], + custom: { + fullName, + description: faker.person.jobTitle(), + } }; } }); @@ -118,8 +125,8 @@ import ('https://cdn.jsdelivr.net/npm/@faker-js/faker@9/dist/index.min.js').then const users = Object.keys(userDb).map((id) => ({ id, name: userDb[id].name, - image: userDb[id].image, - description: userDb[id].description + image: userDb[id].avatar, + description: userDb[id].custom.description })); resolve(users); }, fakeDelay); @@ -320,9 +327,6 @@ import ('https://cdn.jsdelivr.net/npm/@faker-js/faker@9/dist/index.min.js').then }); setTimeout(() => done({ conversations: fetchedConversations }), fakeDelay); }; - - // Read the above `getAuthorInfo` function to see how this could be implemented - const tinycomments_fetch_author_info = (done) => done(getAuthorInfo(currentUid)); tinymce.init({ selector: 'textarea#comments-callback-with-mentions', @@ -350,9 +354,16 @@ import ('https://cdn.jsdelivr.net/npm/@faker-js/faker@9/dist/index.min.js').then mentions_select, tinycomments_mode: 'callback', - tinycomments_author: currentUser.id, - tinycomments_author_name: currentUser.fullName, - tinycomments_avatar: currentUser.image, + user_id: currentUser.id, + fetch_users: (userIds) => { + return Promise.all( + userIds.map( + (userId) => new Promise( + (resolve) => resolve(userDb[userId] || { id: userId }) + ) + ) + ) + }, tinycomments_create, tinycomments_reply, tinycomments_delete, @@ -362,6 +373,5 @@ import ('https://cdn.jsdelivr.net/npm/@faker-js/faker@9/dist/index.min.js').then tinycomments_delete_comment, tinycomments_edit_comment, tinycomments_fetch, - tinycomments_fetch_author_info }); }); diff --git a/modules/ROOT/examples/live-demos/comments-embedded-with-mentions/index.js b/modules/ROOT/examples/live-demos/comments-embedded-with-mentions/index.js index c2639167f7..5ff44ba8bc 100644 --- a/modules/ROOT/examples/live-demos/comments-embedded-with-mentions/index.js +++ b/modules/ROOT/examples/live-demos/comments-embedded-with-mentions/index.js @@ -1,19 +1,28 @@ import ('https://cdn.jsdelivr.net/npm/@faker-js/faker@9/dist/index.min.js').then(({ faker }) => { - const adminUser = { - id: 'johnsmith', - name: 'John Smith', - fullName: 'John Smith', - description: 'Company Founder', - image: "https://i.pravatar.cc/150?img=11" + /* This represents a database of users on the server */ + const userDb = { + 'johnsmith': { + id: 'johnsmith', + name: 'John Smith', + avatar: 'https://i.pravatar.cc/150?img=11', + custom: { + fullName: 'John Smith', + description: 'Company Founder', + } + }, + 'jennynichols': { + id: 'jennynichols', + name: 'Jenny Nichols', + avatar: 'https://i.pravatar.cc/150?img=10', + custom: { + fullName: 'Jenny Nichols', + description: 'Marketing Director', + } + } }; - const currentUser = { - id: 'jennynichols', - name: 'Jenny Nichols', - fullName: 'Jenny Nichols', - description: 'Marketing Director', - image: "https://i.pravatar.cc/150?img=10" - }; + const adminUser = userDb['johnsmith']; + const currentUser = userDb['jennynichols']; const fakeDelay = 500; const numberOfUsers = 200; @@ -24,28 +33,25 @@ import ('https://cdn.jsdelivr.net/npm/@faker-js/faker@9/dist/index.min.js').then const userRequest = {}; const setupFakeServer = () => { - const images = [ adminUser.image, currentUser.image ]; - const userNames = [ adminUser.fullName, currentUser.fullName ]; + const images = [ adminUser.avatar, currentUser.avatar ]; + const userNames = [ adminUser.custom.fullName, currentUser.custom.fullName ]; for (let i = 0; i < numberOfUsers; i++) { images.push(faker.image.avatar()); userNames.push(`${faker.person.firstName()} ${faker.person.lastName()}`); } - - /* This represents a database of users on the server */ - const userDb = { - [adminUser.id]: adminUser, - [currentUser.id]: currentUser - }; + userNames.map((fullName) => { - if ((fullName !== currentUser.fullName) && (fullName !== adminUser.fullName)) { + if ((fullName !== currentUser.custom.fullName) && (fullName !== adminUser.custom.fullName)) { const id = fullName.toLowerCase().replace(/ /g, ''); userDb[id] = { id, name: fullName, - fullName, - description: faker.person.jobTitle(), - image: images[Math.floor(images.length * Math.random())] + avatar: images[Math.floor(images.length * Math.random())], + custom: { + fullName, + description: faker.person.jobTitle(), + } }; } }); @@ -155,9 +161,16 @@ import ('https://cdn.jsdelivr.net/npm/@faker-js/faker@9/dist/index.min.js').then tinycomments_mode: 'embedded', sidebar_show: 'showcomments', - tinycomments_author: currentUser.id, - tinycomments_author_name: currentUser.fullName, - tinycomments_avatar: currentUser.image, + user_id: currentUser.id, + fetch_users: (userIds) => { + return Promise.all( + userIds.map( + (userId) => new Promise( + (resolve) => resolve(userDb[userId] || { id: userId }) + ) + ) + ) + }, tinycomments_can_resolve, }); }); diff --git a/modules/ROOT/examples/live-demos/comments-embedded/index.js b/modules/ROOT/examples/live-demos/comments-embedded/index.js index d0aa9d9c21..6514394809 100644 --- a/modules/ROOT/examples/live-demos/comments-embedded/index.js +++ b/modules/ROOT/examples/live-demos/comments-embedded/index.js @@ -16,7 +16,7 @@ tinymce.init({ quickbars_image_toolbar: 'alignleft aligncenter alignright | rotateleft rotateright | imageoptions', tinycomments_mode: 'embedded', sidebar_show: 'showcomments', - tinycomments_author: currentAuthor, + user_id: currentAuthor, tinycomments_can_resolve: (req, done, fail) => { const allowed = req.comments.length > 0 && req.comments[0].author === currentAuthor; diff --git a/modules/ROOT/examples/live-demos/revisionhistory/index.js b/modules/ROOT/examples/live-demos/revisionhistory/index.js index 946faa0854..7b3b282b87 100644 --- a/modules/ROOT/examples/live-demos/revisionhistory/index.js +++ b/modules/ROOT/examples/live-demos/revisionhistory/index.js @@ -4,6 +4,15 @@ const getRandomDelay = () => { return Math.floor(Math.random() * (maxDelay - minDelay + 1)) + minDelay; }; +/* This represents a database of users on the server */ +const userDb = { + 'john.doe': { + id: 'john.doe', + name: 'John Doe', + avatar: 'https://i.pravatar.cc/150?img=11' + } +}; + const lightRevisions = [ { revisionId: '3', @@ -202,9 +211,15 @@ tinymce.init({ content_style: 'body { font-family:Helvetica,Arial,sans-serif; font-size:16px }', revisionhistory_fetch, revisionhistory_fetch_revision, - revisionhistory_author: { - id: 'john.doe', - name: 'John Doe' - }, + user_id: 'john.doe', + fetch_users: (userIds) => { + return Promise.all( + userIds.map( + (userId) => new Promise( + (resolve) => resolve(userDb[userId] || { id: userId }) + ) + ) + ) + }, revisionhistory_display_author: true }); diff --git a/modules/ROOT/examples/live-demos/suggestededits-access-feedback/example.js b/modules/ROOT/examples/live-demos/suggestededits-access-feedback/example.js new file mode 100644 index 0000000000..49d1a9df43 --- /dev/null +++ b/modules/ROOT/examples/live-demos/suggestededits-access-feedback/example.js @@ -0,0 +1,21 @@ +const tinymceElement = document.querySelector('textarea#suggested-edits'); +const model = tinymceElement.getAttribute('suggestededits-model'); + +tinymce.init({ + selector: 'textarea#suggested-edits', + height: 500, + plugins: 'suggestededits advlist anchor autolink code charmap emoticons fullscreen help image link lists media preview searchreplace table', + toolbar: 'undo redo | suggestededits | styles fontsizeinput | bold italic | align bullist numlist | table link image | code', + user_id: 'michaelcook', + fetch_users: (userIds) => Promise.all(userIds + .map((userId) => + fetch(`/users/${userId}`) // Fetch user data from the server + .then((response) => response.json()) + .catch(() => ({ id: userId })) // Still return a valid user object even if the fetch fails + )), + content_style: 'body { font-family:Helvetica,Arial,sans-serif; font-size:16px }', + readonly: false, // Set to true to prevent edits to the content + suggestededits_access: 'feedback', // Set this value to restrict the permissions in the Suggested Edits view + suggestededits_content: 'html', + suggestededits_model: model +}); \ No newline at end of file diff --git a/modules/ROOT/examples/live-demos/suggestededits-access-feedback/index.html b/modules/ROOT/examples/live-demos/suggestededits-access-feedback/index.html new file mode 100644 index 0000000000..b410001410 --- /dev/null +++ b/modules/ROOT/examples/live-demos/suggestededits-access-feedback/index.html @@ -0,0 +1,44 @@ + diff --git a/modules/ROOT/examples/live-demos/suggestededits-access-feedback/index.js b/modules/ROOT/examples/live-demos/suggestededits-access-feedback/index.js new file mode 100644 index 0000000000..f493d2f43e --- /dev/null +++ b/modules/ROOT/examples/live-demos/suggestededits-access-feedback/index.js @@ -0,0 +1,437 @@ +/** Fake user database */ +const userDb = { + adamhayes: { + id: 'adamhayes', + name: 'Adam Hayes', + avatar: `https://randomuser.me/api/portraits/men/4.jpg`, + }, + martincook: { + id: 'martincook', + name: 'Martin Cook', + avatar: `https://randomuser.me/api/portraits/men/5.jpg`, + }, + kalebwilson: { + id: 'kalebwilson', + name: 'Kaleb Wilson', + avatar: `https://randomuser.me/api/portraits/men/6.jpg`, + }, + sarahjones: { + id: 'sarahjones', + name: 'Sarah Jones', + avatar: `https://randomuser.me/api/portraits/women/1.jpg`, + } +}; + +const model = { + "history": { + "2": [ + { + "id": 1, + "uid": "sarahjones", + "timestamp": 1752576936000, + "feedback": "Nice improvement!" + } + ] + }, + "version": 1, + "contents": [ + { + "type": "p", + "children": [ + { + "type": "img", + "attrs": { + "style": "display: block; margin-left: auto; margin-right: auto;", + "title": "Tiny Logo", + "src": "https://www.tiny.cloud/docs/images/logos/android-chrome-256x256.png", + "alt": "TinyMCE Logo", + "width": "128", + "height": "128" + } + } + ] + }, + { + "type": "h2", + "attrs": { + "style": "text-align: center;" + }, + "children": [ + { + "text": "Welcome to the TinyMCE Suggested Edits " + }, + { + "text": "interactive ", + "opData": { + "id": 1, + "type": "insert", + "uid": "adamhayes", + "timestamp": 1752015064000 + } + }, + { + "text": "demo!" + } + ] + }, + { + "type": "p", + "attrs": { + "style": "text-align: center;" + }, + "children": [ + { + "text": "Try out the Suggested Edits feature" + }, + { + "text": ": type in the editor, apply formatting or delete some content. T", + "opData": { + "id": 2, + "type": "insert", + "uid": "adamhayes", + "timestamp": 1752415064000 + } + }, + { + "text": " by typing in the editor and t", + "opData": { + "id": 2, + "type": "remove", + "uid": "adamhayes", + "timestamp": 1752415064000 + } + }, + { + "text": "hen" + }, + { + "text": ",", + "opData": { + "id": 3, + "type": "insert", + "uid": "adamhayes", + "timestamp": 1752515064000 + } + }, + { + "text": " click" + }, + { + "text": "ing", + "opData": { + "id": 4, + "type": "remove", + "uid": "adamhayes", + "timestamp": 1752515064000 + } + }, + { + "text": " the Review Changes button in the toolbar" + }, + { + "text": " to see your changes", + "opData": { + "id": 5, + "type": "insert", + "uid": "kalebwilson", + "timestamp": 1752615064000 + } + }, + { + "text": "." + } + ] + }, + { + "type": "p", + "attrs": { + "style": "text-align: center;" + }, + "children": [ + { + "text": "And visit the " + }, + { + "text": "pricing page", + "opData": { + "id": 6, + "type": "modify", + "uid": "kalebwilson", + "timestamp": 1752615064000 + }, + "format": [ + { + "type": "a", + "attrs": { + "href": "https://www.tiny.cloud/pricing" + } + } + ], + "oldFormat": [ + { + "type": "a", + "attrs": { + "href": "https://www.tiny.cloud/pricing" + } + }, + "em" + ] + }, + { + "text": " to learn more about our Premium plugins." + } + ] + }, + { + "type": "h2", + "children": [ + { + "text": "A simple table to play with" + } + ] + }, + { + "type": "table", + "attrs": { + "style": "border-collapse: collapse; width: 100%;", + "border": "1" + }, + "children": [ + { + "type": "thead", + "children": [ + { + "type": "tr", + "attrs": { + "style": "text-align: left;" + }, + "children": [ + { + "type": "th", + "children": [ + { + "text": "Product" + } + ] + }, + { + "type": "th", + "children": [ + { + "text": "Cost" + } + ] + }, + { + "type": "th", + "children": [ + { + "text": "Really?" + } + ] + } + ] + } + ] + }, + { + "type": "tbody", + "children": [ + { + "type": "tr", + "children": [ + { + "type": "td", + "children": [ + { + "text": "TinyMCE Cloud" + } + ] + }, + { + "type": "td", + "children": [ + { + "text": "Get started for free" + } + ] + }, + { + "type": "td", + "children": [ + { + "text": "Yes!", + "format": [ + "strong" + ] + } + ] + } + ] + }, + { + "type": "tr", + "children": [ + { + "type": "td", + "children": [ + { + "text": "Plupload" + } + ] + }, + { + "type": "td", + "children": [ + { + "text": "Free" + } + ] + }, + { + "type": "td", + "children": [ + { + "text": "Yes!", + "format": [ + "strong" + ] + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "h2", + "opData": { + "id": 7, + "type": "insert", + "uid": "martincook", + "timestamp": 1752576331000 + }, + "children": [ + { + "text": "Found a bug?" + } + ] + }, + { + "type": "p", + "children": [ + { + "text": " ", + "opData": { + "id": 7, + "type": "remove", + "uid": "martincook", + "timestamp": 1752576331000 + } + }, + { + "text": "If you believe you have found a bug please create an issue on the ", + "opData": { + "id": 7, + "type": "insert", + "uid": "martincook", + "timestamp": 1752576331000 + } + }, + { + "text": "GitHub repo", + "opData": { + "id": 7, + "type": "insert", + "uid": "martincook", + "timestamp": 1752576331000 + }, + "format": [ + { + "type": "a", + "attrs": { + "href": "https://github.com/tinymce/tinymce/issues" + } + } + ] + }, + { + "text": " to report it to the developers.", + "opData": { + "id": 7, + "type": "insert", + "uid": "martincook", + "timestamp": 1752576331000 + } + } + ] + }, + { + "type": "h2", + "children": [ + { + "text": "Finally…" + } + ] + }, + { + "type": "p", + "children": [ + { + "text": "Don’t forget to check out " + }, + { + "text": "Plupload", + "format": [ + { + "type": "a", + "attrs": { + "href": "http://www.plupload.com", + "target": "_blank", + "rel": "noopener" + } + } + ] + }, + { + "text": ", the upload solution featuring HTML5 upload support." + } + ] + }, + { + "type": "p", + "children": [ + { + "text": "Thanks for supporting TinyMCE. We hope it helps you and your users create great content." + } + ] + }, + { + "type": "p", + "children": [ + { + "text": "All the best from the TinyMCE team." + } + ] + } + ] +}; + +tinymce.init({ + selector: 'textarea#suggestededits-access-feedback', + height: 500, + plugins: 'suggestededits advlist anchor autolink code charmap emoticons fullscreen help image link lists media preview searchreplace table', + toolbar: 'undo redo | suggestededits | styles fontsizeinput | bold italic | align bullist numlist | table link image | code', + user_id: 'kalebwilson', + fetch_users: (userIds) => Promise.all(userIds + .map((userId) => new Promise((resolve) => + resolve(userDb[userId] || { id: userId })) + )), + content_style: 'body { font-family:Helvetica,Arial,sans-serif; font-size:16px }', + readonly: false, + suggestededits_access: 'feedback', + suggestededits_content: 'html', + suggestededits_model: model +}); \ No newline at end of file diff --git a/modules/ROOT/examples/live-demos/suggestededits-access-read/example.js b/modules/ROOT/examples/live-demos/suggestededits-access-read/example.js new file mode 100644 index 0000000000..9fee7e94ad --- /dev/null +++ b/modules/ROOT/examples/live-demos/suggestededits-access-read/example.js @@ -0,0 +1,21 @@ +const tinymceElement = document.querySelector('textarea#suggested-edits'); +const model = tinymceElement.getAttribute('suggestededits-model'); + +tinymce.init({ + selector: 'textarea#suggested-edits', + height: 500, + plugins: 'suggestededits advlist anchor autolink code charmap emoticons fullscreen help image link lists media preview searchreplace table', + toolbar: 'undo redo | suggestededits | styles fontsizeinput | bold italic | align bullist numlist | table link image | code', + user_id: 'michaelcook', + fetch_users: (userIds) => Promise.all(userIds + .map((userId) => + fetch(`/users/${userId}`) // Fetch user data from the server + .then((response) => response.json()) + .catch(() => ({ id: userId })) // Still return a valid user object even if the fetch fails + )), + content_style: 'body { font-family:Helvetica,Arial,sans-serif; font-size:16px }', + readonly: false, // Set to true to prevent edits to the content + suggestededits_access: 'read', // Set this value to restrict the permissions in the Suggested Edits view + suggestededits_content: 'html', + suggestededits_model: model +}); \ No newline at end of file diff --git a/modules/ROOT/examples/live-demos/suggestededits-access-read/index.html b/modules/ROOT/examples/live-demos/suggestededits-access-read/index.html new file mode 100644 index 0000000000..d0487689c2 --- /dev/null +++ b/modules/ROOT/examples/live-demos/suggestededits-access-read/index.html @@ -0,0 +1,44 @@ + diff --git a/modules/ROOT/examples/live-demos/suggestededits-access-read/index.js b/modules/ROOT/examples/live-demos/suggestededits-access-read/index.js new file mode 100644 index 0000000000..99336e6129 --- /dev/null +++ b/modules/ROOT/examples/live-demos/suggestededits-access-read/index.js @@ -0,0 +1,437 @@ +/** Fake user database */ +const userDb = { + adamhayes: { + id: 'adamhayes', + name: 'Adam Hayes', + avatar: `https://randomuser.me/api/portraits/men/4.jpg`, + }, + martincook: { + id: 'martincook', + name: 'Martin Cook', + avatar: `https://randomuser.me/api/portraits/men/5.jpg`, + }, + kalebwilson: { + id: 'kalebwilson', + name: 'Kaleb Wilson', + avatar: `https://randomuser.me/api/portraits/men/6.jpg`, + }, + sarahjones: { + id: 'sarahjones', + name: 'Sarah Jones', + avatar: `https://randomuser.me/api/portraits/women/1.jpg`, + } +}; + +const model = { + "history": { + "2": [ + { + "id": 1, + "uid": "sarahjones", + "timestamp": 1752576936000, + "feedback": "Nice improvement!" + } + ] + }, + "version": 1, + "contents": [ + { + "type": "p", + "children": [ + { + "type": "img", + "attrs": { + "style": "display: block; margin-left: auto; margin-right: auto;", + "title": "Tiny Logo", + "src": "https://www.tiny.cloud/docs/images/logos/android-chrome-256x256.png", + "alt": "TinyMCE Logo", + "width": "128", + "height": "128" + } + } + ] + }, + { + "type": "h2", + "attrs": { + "style": "text-align: center;" + }, + "children": [ + { + "text": "Welcome to the TinyMCE Suggested Edits " + }, + { + "text": "interactive ", + "opData": { + "id": 1, + "type": "insert", + "uid": "adamhayes", + "timestamp": 1752015064000 + } + }, + { + "text": "demo!" + } + ] + }, + { + "type": "p", + "attrs": { + "style": "text-align: center;" + }, + "children": [ + { + "text": "Try out the Suggested Edits feature" + }, + { + "text": ": type in the editor, apply formatting or delete some content. T", + "opData": { + "id": 2, + "type": "insert", + "uid": "adamhayes", + "timestamp": 1752415064000 + } + }, + { + "text": " by typing in the editor and t", + "opData": { + "id": 2, + "type": "remove", + "uid": "adamhayes", + "timestamp": 1752415064000 + } + }, + { + "text": "hen" + }, + { + "text": ",", + "opData": { + "id": 3, + "type": "insert", + "uid": "adamhayes", + "timestamp": 1752515064000 + } + }, + { + "text": " click" + }, + { + "text": "ing", + "opData": { + "id": 4, + "type": "remove", + "uid": "adamhayes", + "timestamp": 1752515064000 + } + }, + { + "text": " the Review Changes button in the toolbar" + }, + { + "text": " to see your changes", + "opData": { + "id": 5, + "type": "insert", + "uid": "kalebwilson", + "timestamp": 1752615064000 + } + }, + { + "text": "." + } + ] + }, + { + "type": "p", + "attrs": { + "style": "text-align: center;" + }, + "children": [ + { + "text": "And visit the " + }, + { + "text": "pricing page", + "opData": { + "id": 6, + "type": "modify", + "uid": "kalebwilson", + "timestamp": 1752615064000 + }, + "format": [ + { + "type": "a", + "attrs": { + "href": "https://www.tiny.cloud/pricing" + } + } + ], + "oldFormat": [ + { + "type": "a", + "attrs": { + "href": "https://www.tiny.cloud/pricing" + } + }, + "em" + ] + }, + { + "text": " to learn more about our Premium plugins." + } + ] + }, + { + "type": "h2", + "children": [ + { + "text": "A simple table to play with" + } + ] + }, + { + "type": "table", + "attrs": { + "style": "border-collapse: collapse; width: 100%;", + "border": "1" + }, + "children": [ + { + "type": "thead", + "children": [ + { + "type": "tr", + "attrs": { + "style": "text-align: left;" + }, + "children": [ + { + "type": "th", + "children": [ + { + "text": "Product" + } + ] + }, + { + "type": "th", + "children": [ + { + "text": "Cost" + } + ] + }, + { + "type": "th", + "children": [ + { + "text": "Really?" + } + ] + } + ] + } + ] + }, + { + "type": "tbody", + "children": [ + { + "type": "tr", + "children": [ + { + "type": "td", + "children": [ + { + "text": "TinyMCE Cloud" + } + ] + }, + { + "type": "td", + "children": [ + { + "text": "Get started for free" + } + ] + }, + { + "type": "td", + "children": [ + { + "text": "Yes!", + "format": [ + "strong" + ] + } + ] + } + ] + }, + { + "type": "tr", + "children": [ + { + "type": "td", + "children": [ + { + "text": "Plupload" + } + ] + }, + { + "type": "td", + "children": [ + { + "text": "Free" + } + ] + }, + { + "type": "td", + "children": [ + { + "text": "Yes!", + "format": [ + "strong" + ] + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "h2", + "opData": { + "id": 7, + "type": "insert", + "uid": "martincook", + "timestamp": 1752576331000 + }, + "children": [ + { + "text": "Found a bug?" + } + ] + }, + { + "type": "p", + "children": [ + { + "text": " ", + "opData": { + "id": 7, + "type": "remove", + "uid": "martincook", + "timestamp": 1752576331000 + } + }, + { + "text": "If you believe you have found a bug please create an issue on the ", + "opData": { + "id": 7, + "type": "insert", + "uid": "martincook", + "timestamp": 1752576331000 + } + }, + { + "text": "GitHub repo", + "opData": { + "id": 7, + "type": "insert", + "uid": "martincook", + "timestamp": 1752576331000 + }, + "format": [ + { + "type": "a", + "attrs": { + "href": "https://github.com/tinymce/tinymce/issues" + } + } + ] + }, + { + "text": " to report it to the developers.", + "opData": { + "id": 7, + "type": "insert", + "uid": "martincook", + "timestamp": 1752576331000 + } + } + ] + }, + { + "type": "h2", + "children": [ + { + "text": "Finally…" + } + ] + }, + { + "type": "p", + "children": [ + { + "text": "Don’t forget to check out " + }, + { + "text": "Plupload", + "format": [ + { + "type": "a", + "attrs": { + "href": "http://www.plupload.com", + "target": "_blank", + "rel": "noopener" + } + } + ] + }, + { + "text": ", the upload solution featuring HTML5 upload support." + } + ] + }, + { + "type": "p", + "children": [ + { + "text": "Thanks for supporting TinyMCE. We hope it helps you and your users create great content." + } + ] + }, + { + "type": "p", + "children": [ + { + "text": "All the best from the TinyMCE team." + } + ] + } + ] +}; + +tinymce.init({ + selector: 'textarea#suggestededits-access-read', + height: 500, + plugins: 'suggestededits advlist anchor autolink code charmap emoticons fullscreen help image link lists media preview searchreplace table', + toolbar: 'undo redo | suggestededits | styles fontsizeinput | bold italic | align bullist numlist | table link image | code', + user_id: 'kalebwilson', + fetch_users: (userIds) => Promise.all(userIds + .map((userId) => new Promise((resolve) => + resolve(userDb[userId] || { id: userId })) + )), + content_style: 'body { font-family:Helvetica,Arial,sans-serif; font-size:16px }', + readonly: false, + suggestededits_access: 'read', + suggestededits_content: 'html', + suggestededits_model: model, +}); \ No newline at end of file diff --git a/modules/ROOT/examples/live-demos/suggestededits/example.js b/modules/ROOT/examples/live-demos/suggestededits/example.js new file mode 100644 index 0000000000..345c68ebc5 --- /dev/null +++ b/modules/ROOT/examples/live-demos/suggestededits/example.js @@ -0,0 +1,20 @@ +const tinymceElement = document.querySelector('textarea#suggested-edits'); +const model = tinymceElement.getAttribute('suggestededits-model'); + +tinymce.init({ + selector: 'textarea#suggested-edits', + height: 500, + plugins: 'suggestededits advlist anchor autolink code charmap emoticons fullscreen help image link lists media preview searchreplace table', + toolbar: 'undo redo | suggestededits | styles fontsizeinput | bold italic | align bullist numlist | table link image | code', + user_id: 'michaelcook', + fetch_users: (userIds) => Promise.all(userIds + .map((userId) => + fetch(`/users/${userId}`) // Fetch user data from the server + .then((response) => response.json()) + .catch(() => ({ id: userId })) // Still return a valid user object even if the fetch fails + )), + content_style: 'body { font-family:Helvetica,Arial,sans-serif; font-size:16px }', + suggestededits_model: model, + suggestededits_access: 'full', + suggestededits_content: 'html' +}); \ No newline at end of file diff --git a/modules/ROOT/examples/live-demos/suggestededits/index.html b/modules/ROOT/examples/live-demos/suggestededits/index.html new file mode 100644 index 0000000000..22a924a1b2 --- /dev/null +++ b/modules/ROOT/examples/live-demos/suggestededits/index.html @@ -0,0 +1,44 @@ + diff --git a/modules/ROOT/examples/live-demos/suggestededits/index.js b/modules/ROOT/examples/live-demos/suggestededits/index.js new file mode 100644 index 0000000000..22a13fc46e --- /dev/null +++ b/modules/ROOT/examples/live-demos/suggestededits/index.js @@ -0,0 +1,436 @@ +/** Fake user database */ +const userDb = { + adamhayes: { + id: 'adamhayes', + name: 'Adam Hayes', + avatar: `https://randomuser.me/api/portraits/men/4.jpg`, + }, + martincook: { + id: 'martincook', + name: 'Martin Cook', + avatar: `https://randomuser.me/api/portraits/men/5.jpg`, + }, + kalebwilson: { + id: 'kalebwilson', + name: 'Kaleb Wilson', + avatar: `https://randomuser.me/api/portraits/men/6.jpg`, + }, + sarahjones: { + id: 'sarahjones', + name: 'Sarah Jones', + avatar: `https://randomuser.me/api/portraits/women/1.jpg`, + } +}; + +const model = { + "history": { + "2": [ + { + "id": 1, + "uid": "sarahjones", + "timestamp": 1752576936000, + "feedback": "Nice improvement!" + } + ] + }, + "version": 1, + "contents": [ + { + "type": "p", + "children": [ + { + "type": "img", + "attrs": { + "style": "display: block; margin-left: auto; margin-right: auto;", + "title": "Tiny Logo", + "src": "https://www.tiny.cloud/docs/images/logos/android-chrome-256x256.png", + "alt": "TinyMCE Logo", + "width": "128", + "height": "128" + } + } + ] + }, + { + "type": "h2", + "attrs": { + "style": "text-align: center;" + }, + "children": [ + { + "text": "Welcome to the TinyMCE Suggested Edits " + }, + { + "text": "interactive ", + "opData": { + "id": 1, + "type": "insert", + "uid": "adamhayes", + "timestamp": 1752015064000 + } + }, + { + "text": "demo!" + } + ] + }, + { + "type": "p", + "attrs": { + "style": "text-align: center;" + }, + "children": [ + { + "text": "Try out the Suggested Edits feature" + }, + { + "text": ": type in the editor, apply formatting or delete some content. T", + "opData": { + "id": 2, + "type": "insert", + "uid": "adamhayes", + "timestamp": 1752415064000 + } + }, + { + "text": " by typing in the editor and t", + "opData": { + "id": 2, + "type": "remove", + "uid": "adamhayes", + "timestamp": 1752415064000 + } + }, + { + "text": "hen" + }, + { + "text": ",", + "opData": { + "id": 3, + "type": "insert", + "uid": "adamhayes", + "timestamp": 1752515064000 + } + }, + { + "text": " click" + }, + { + "text": "ing", + "opData": { + "id": 4, + "type": "remove", + "uid": "adamhayes", + "timestamp": 1752515064000 + } + }, + { + "text": " the Review Changes button in the toolbar" + }, + { + "text": " to see your changes", + "opData": { + "id": 5, + "type": "insert", + "uid": "kalebwilson", + "timestamp": 1752615064000 + } + }, + { + "text": "." + } + ] + }, + { + "type": "p", + "attrs": { + "style": "text-align: center;" + }, + "children": [ + { + "text": "And visit the " + }, + { + "text": "pricing page", + "opData": { + "id": 6, + "type": "modify", + "uid": "kalebwilson", + "timestamp": 1752615064000 + }, + "format": [ + { + "type": "a", + "attrs": { + "href": "https://www.tiny.cloud/pricing" + } + } + ], + "oldFormat": [ + { + "type": "a", + "attrs": { + "href": "https://www.tiny.cloud/pricing" + } + }, + "em" + ] + }, + { + "text": " to learn more about our Premium plugins." + } + ] + }, + { + "type": "h2", + "children": [ + { + "text": "A simple table to play with" + } + ] + }, + { + "type": "table", + "attrs": { + "style": "border-collapse: collapse; width: 100%;", + "border": "1" + }, + "children": [ + { + "type": "thead", + "children": [ + { + "type": "tr", + "attrs": { + "style": "text-align: left;" + }, + "children": [ + { + "type": "th", + "children": [ + { + "text": "Product" + } + ] + }, + { + "type": "th", + "children": [ + { + "text": "Cost" + } + ] + }, + { + "type": "th", + "children": [ + { + "text": "Really?" + } + ] + } + ] + } + ] + }, + { + "type": "tbody", + "children": [ + { + "type": "tr", + "children": [ + { + "type": "td", + "children": [ + { + "text": "TinyMCE Cloud" + } + ] + }, + { + "type": "td", + "children": [ + { + "text": "Get started for free" + } + ] + }, + { + "type": "td", + "children": [ + { + "text": "Yes!", + "format": [ + "strong" + ] + } + ] + } + ] + }, + { + "type": "tr", + "children": [ + { + "type": "td", + "children": [ + { + "text": "Plupload" + } + ] + }, + { + "type": "td", + "children": [ + { + "text": "Free" + } + ] + }, + { + "type": "td", + "children": [ + { + "text": "Yes!", + "format": [ + "strong" + ] + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "h2", + "opData": { + "id": 7, + "type": "insert", + "uid": "martincook", + "timestamp": 1752576331000 + }, + "children": [ + { + "text": "Found a bug?" + } + ] + }, + { + "type": "p", + "children": [ + { + "text": " ", + "opData": { + "id": 7, + "type": "remove", + "uid": "martincook", + "timestamp": 1752576331000 + } + }, + { + "text": "If you believe you have found a bug please create an issue on the ", + "opData": { + "id": 7, + "type": "insert", + "uid": "martincook", + "timestamp": 1752576331000 + } + }, + { + "text": "GitHub repo", + "opData": { + "id": 7, + "type": "insert", + "uid": "martincook", + "timestamp": 1752576331000 + }, + "format": [ + { + "type": "a", + "attrs": { + "href": "https://github.com/tinymce/tinymce/issues" + } + } + ] + }, + { + "text": " to report it to the developers.", + "opData": { + "id": 7, + "type": "insert", + "uid": "martincook", + "timestamp": 1752576331000 + } + } + ] + }, + { + "type": "h2", + "children": [ + { + "text": "Finally…" + } + ] + }, + { + "type": "p", + "children": [ + { + "text": "Don’t forget to check out " + }, + { + "text": "Plupload", + "format": [ + { + "type": "a", + "attrs": { + "href": "http://www.plupload.com", + "target": "_blank", + "rel": "noopener" + } + } + ] + }, + { + "text": ", the upload solution featuring HTML5 upload support." + } + ] + }, + { + "type": "p", + "children": [ + { + "text": "Thanks for supporting TinyMCE. We hope it helps you and your users create great content." + } + ] + }, + { + "type": "p", + "children": [ + { + "text": "All the best from the TinyMCE team." + } + ] + } + ] +}; + +tinymce.init({ + selector: 'textarea#suggestededits', + height: 500, + plugins: 'suggestededits advlist anchor autolink code charmap emoticons fullscreen help image link lists media preview searchreplace table', + toolbar: 'undo redo | suggestededits | styles fontsizeinput | bold italic | align bullist numlist | table link image | code', + user_id: 'kalebwilson', + fetch_users: (userIds) => Promise.all(userIds + .map((userId) => new Promise((resolve) => + resolve(userDb[userId] || { id: userId })) + )), + content_style: 'body { font-family:Helvetica,Arial,sans-serif; font-size:16px }', + suggestededits_model: model, + suggestededits_content: 'html', + suggestededits_access: 'full' +}); diff --git a/modules/ROOT/images/icons/checkmark-filled.svg b/modules/ROOT/images/icons/checkmark-filled.svg new file mode 100644 index 0000000000..5fb476da1d --- /dev/null +++ b/modules/ROOT/images/icons/checkmark-filled.svg @@ -0,0 +1,3 @@ + + + diff --git a/modules/ROOT/images/icons/close-filled.svg b/modules/ROOT/images/icons/close-filled.svg new file mode 100644 index 0000000000..f4362e0de8 --- /dev/null +++ b/modules/ROOT/images/icons/close-filled.svg @@ -0,0 +1,3 @@ + + + diff --git a/modules/ROOT/images/icons/feedback.svg b/modules/ROOT/images/icons/feedback.svg new file mode 100644 index 0000000000..8aefd52267 --- /dev/null +++ b/modules/ROOT/images/icons/feedback.svg @@ -0,0 +1,3 @@ + + + diff --git a/modules/ROOT/images/icons/suggestededits-badge.svg b/modules/ROOT/images/icons/suggestededits-badge.svg new file mode 100644 index 0000000000..4c1409ca15 --- /dev/null +++ b/modules/ROOT/images/icons/suggestededits-badge.svg @@ -0,0 +1 @@ + diff --git a/modules/ROOT/images/icons/suggestededits.svg b/modules/ROOT/images/icons/suggestededits.svg new file mode 100644 index 0000000000..dbd5bb6d0f --- /dev/null +++ b/modules/ROOT/images/icons/suggestededits.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/modules/ROOT/nav.adoc b/modules/ROOT/nav.adoc index 04ebfa9fe1..e62abddba1 100644 --- a/modules/ROOT/nav.adoc +++ b/modules/ROOT/nav.adoc @@ -62,6 +62,7 @@ *** xref:localize-your-language.adoc[Localization] *** xref:spell-checking.adoc[Spell checking] *** xref:editor-content-css.adoc[CSS for rendering content] +*** xref:userlookup.adoc[Using the UserLookup API] ** Environment setup guides *** React framework **** xref:react-cloud.adoc[Using the Tiny Cloud] @@ -345,6 +346,7 @@ **** xref:introduction-to-tiny-spellchecker.adoc[Spell Checker] **** xref:custom-dictionaries-for-tiny-spellchecker.adoc[Adding custom dictionaries] *** xref:autocorrect.adoc[Spelling Autocorrect] +*** xref:suggestededits.adoc[Suggested Edits] *** xref:tableofcontents.adoc[Table of Contents] *** xref:advanced-templates.adoc[Templates] *** Tiny Drive diff --git a/modules/ROOT/pages/available-toolbar-buttons.adoc b/modules/ROOT/pages/available-toolbar-buttons.adoc index aeb089d9cd..0a95913626 100644 --- a/modules/ROOT/pages/available-toolbar-buttons.adoc +++ b/modules/ROOT/pages/available-toolbar-buttons.adoc @@ -239,6 +239,13 @@ include::partial$misc/plugin-toolbar-button-id-boilerplate.adoc[] include::partial$misc/plugin-toolbar-button-id-boilerplate.adoc[] :!pluginpage: +:plugincategory: premium +:pluginname: Suggested Edits +:plugincode: suggestededits +:pluginpage: suggestededits.adoc +include::partial$misc/plugin-toolbar-button-id-boilerplate.adoc[] +:!pluginpage: + :plugincategory: opensource :pluginname: Table :plugincode: table diff --git a/modules/ROOT/pages/comments-callback-mode.adoc b/modules/ROOT/pages/comments-callback-mode.adoc index 85275ec9fc..a5725f33bd 100644 --- a/modules/ROOT/pages/comments-callback-mode.adoc +++ b/modules/ROOT/pages/comments-callback-mode.adoc @@ -67,6 +67,10 @@ include::partial$configuration/tinycomments_resolve.adoc[leveloffset=+1] include::partial$configuration/tinycomments_fetch.adoc[leveloffset=+1] +include::partial$configuration/user_id.adoc[leveloffset=+1] + +include::partial$configuration/fetch_users.adoc[leveloffset=+1] + include::partial$configuration/tinycomments_fetch_author_info.adoc[leveloffset=+1] include::partial$plugins/comments-open-sidebar.adoc[] diff --git a/modules/ROOT/pages/comments-embedded-mode.adoc b/modules/ROOT/pages/comments-embedded-mode.adoc index f0f8c1fa6f..280630f5bf 100644 --- a/modules/ROOT/pages/comments-embedded-mode.adoc +++ b/modules/ROOT/pages/comments-embedded-mode.adoc @@ -11,17 +11,33 @@ To add the {pluginname} plugin in embedded mode to the {productname}, configure [source,js] ---- +const userDb = { + 'author': { + id: 'author', + name: 'Name of the commenter', + avatar: 'https://example.com/avatar/john.png' + }, +}; + tinymce.init({ selector: 'textarea', // change this value according to your html plugins: 'tinycomments', toolbar: 'addcomment showcomments', - tinycomments_author: 'author', - tinycomments_author_name: 'Name of the commenter', + user_id: 'author', + fetch_users: (userIds) => { + return Promise.all( + userIds.map( + (userId) => new Promise( + (resolve) => resolve(userDb[userId] || { id: userId }) + ) + ) + ) + }, tinycomments_mode: 'embedded' }); ---- -This is the minimum recommended setup for the {pluginname} plugin in embedded mode. If the `+tinycomments_author+` and `+tinycomments_author_name+` options are not configured, all users will be assigned the name "_ANON_". +This is the minimum recommended setup for the {pluginname} plugin in embedded mode. If the `+user_id+` or `+tinycomments_author+` options are not configured, all users will be assigned the name "_ANON_". [[comments-embedded-live-demo]] == Interactive example @@ -30,6 +46,10 @@ liveDemo::comments-embedded[] == Options +include::partial$configuration/user_id.adoc[leveloffset=+1] + +include::partial$configuration/fetch_users.adoc[leveloffset=+1] + include::partial$configuration/tinycomments_author.adoc[leveloffset=+1] include::partial$configuration/tinycomments_author_avatar.adoc[leveloffset=+1] diff --git a/modules/ROOT/pages/comments-with-mentions.adoc b/modules/ROOT/pages/comments-with-mentions.adoc index 950c59350b..f648a0ddea 100644 --- a/modules/ROOT/pages/comments-with-mentions.adoc +++ b/modules/ROOT/pages/comments-with-mentions.adoc @@ -64,9 +64,18 @@ include::partial$configuration/tinycomments_lookup.adoc[leveloffset=+1] When using embedded mode, the {pluginname} plugin requires the following options: +* xref:user_id[`+user_id+`] +* xref:fetch_users[`+fetch_users+`] + +Or: + * xref:tinycomments_author[`+tinycomments_author+`] * xref:tinycomments_author_name[`+tinycomments_author_name+`] +include::partial$configuration/user_id.adoc[leveloffset=+1] + +include::partial$configuration/fetch_users.adoc[leveloffset=+1] + include::partial$configuration/tinycomments_author.adoc[leveloffset=+1] include::partial$configuration/tinycomments_author_name.adoc[leveloffset=+1] diff --git a/modules/ROOT/pages/revisionhistory.adoc b/modules/ROOT/pages/revisionhistory.adoc index 287e7cd506..0df0343897 100644 --- a/modules/ROOT/pages/revisionhistory.adoc +++ b/modules/ROOT/pages/revisionhistory.adoc @@ -109,6 +109,10 @@ include::partial$configuration/revisionhistory_fetch_revision.adoc[leveloffset=+ include::partial$configuration/revisionhistory_allow_restore.adoc[leveloffset=+1] +include::partial$configuration/user_id.adoc[leveloffset=+1] + +include::partial$configuration/fetch_users.adoc[leveloffset=+1] + include::partial$configuration/revisionhistory_author.adoc[leveloffset=+1] include::partial$configuration/revisionhistory_display_author.adoc[leveloffset=+1] diff --git a/modules/ROOT/pages/suggestededits.adoc b/modules/ROOT/pages/suggestededits.adoc new file mode 100644 index 0000000000..0529de12f1 --- /dev/null +++ b/modules/ROOT/pages/suggestededits.adoc @@ -0,0 +1,163 @@ += Suggested Edits Plugin +:navtitle: Suggested Edits +:description: Suggested Edits keeps track of changes and review edits made by multiple authors in a document. +:description_short: A view of tracked changes made by multiple authors +:keywords: plugin, Suggested Edits, changes, diff, track changes, review changes, collaboration, multiple authors +:pluginname: Suggested Edits +:plugincode: suggestededits +:plugincategory: premium + +include::partial$misc/admon-paid-addon-pricing.adoc[] + +The {pluginname} plugin allows multiple users to collaborate on a document. The review window shows which user suggested which edits, whether they added, removed, modified, or replaced any content, and allows users to provide feedback on those suggestions or give a final review by accepting or rejecting them. + +== Interactive example + +liveDemo::{plugincode}[] + +include::partial$misc/admon-iframe-only.adoc[] + +== How it works + +The {pluginname} plugin keeps track of every edit made to the document by the current user and stores this metadata in an internal model of the document. These suggestions can then be reviewed in the Review Edits view, where each edit is highlighted in the document, and where users can accept, reject, or provide feedback. The Review Edits view is accessible via either the `suggestededits` toolbar button or menu button within the `View` menu. + +== The model + +The {pluginname} model is a JSON object representing the document along with all unreviewed edits and feedback. The model must be kept in sync with the editor content and loaded into the editor at the same time as the content. + +The structure of the model is not documented and should not be relied upon. + +The model can be retrieved from the plugin using the xref:#get_model[`+getModel+`] API, saved externally alongside the document, and loaded into the editor with the xref:#suggestededits_model[`+suggestededits_model+`] option. This ensures that the document and the model are in sync and every user's contributions are tracked correctly. If the model and content are out of sync when the editor loads, the difference between them will be applied as a suggested edit by the current user. If a model is not provided in the editor configuration or is set to `+undefined+`, the plugin will generate a new model from the initial content. + +Alternatively the xref:#suggestededits_content[`+suggestededits_content+`] option allows the model to generate the editor content, in which case the two do not need to be kept in sync. + +== Reviewing edits + +The Review Edits view can be used to view and reviewing edits made by multiple authors. The available actions in this view depends on the xref:#suggestededits_access[`+suggestededits_access+` option]. + +=== Header + +The view contains a few controls to manage the review process: + +* Show edits: Toggles whether suggested edits are shown. When hidden, the view shows what the document will look like if the review is completed. +* Complete review: Ends the review, applying resolved suggestions to the document. Unresolved suggestions remain in the document for future review. +* Cancel: Ignores any resolved suggestions and makes no change to the document. Feedback given on suggestions will be retained. + +=== Document + +The current editor document is displayed in a sandboxed iframe, with each suggested edit highlighted as they appear in the document. The following color coding is used to indicate the type of change: + +* Green: Added content. +* Blue: Modified attributes or formatting (e.g. bold, italic, etc.). +* Red: Removed content. + +Replaced content is represented as both added and removed content, and indicates that some content was removed and replaced with new content in a single edit. When a suggestion is selected, whether in the document or in the sidebar, the corresponding highlighted suggestion in the document will be outlined in blue and scrolled into view. + +=== Sidebar + +Each suggested edit is listed as a card in the sidebar and color coded by the type of change, along with the user who made the suggestion, when the edit was made, and any feedback provided on that suggestion. When selected, each suggestion can be handled in the following ways: + +* Accept: Resolves the suggestion, applying the edit to the document when the review is completed. +* Reject: Resolves the suggestion, turning back the edit to the original state. +* Revert: Reverts the current "Accept" or "Reject" resolution on the suggestion. +* Provide feedback: Opens a text area for users to provide feedback on the suggestion. + +Feedback is shown in chronological order beneath the card details when the card is selected. Feedback allows users to discuss suggestions before resolving them. The feedback author can edit or delete their own feedback, with the appropriate permissions in the xref:#suggestededits_access[`+suggestededits_access+`] option. + +At the top of the sidebar there is a dropdown menu to apply review actions in bulk to all suggested edits: + +* Accept all. +* Reject all. +* Revert all. + +=== Finishing a review + +When completing a review, resolved suggestions will be applied to the document and will no longer be tracked in the model as a suggestion. Feedback on resolved suggestions is discarded. Any "accepted" edits will remain in the document, and any "rejected" edits will be reverted to the state before the suggestion was made. + +Review actions will apply the following to the document: + +When added content is: + +* Accepted: The content will remain in the document. +* Rejected: The content will be removed from the document. + +When modified content is: + +* Accepted: The content will retain the current formats and attributes. +* Rejected: The content will revert to modified formats and attributes to match the content state before the edit was made. + +When removed content is: + +* Accepted: The content will be removed from the document. +* Rejected: The removed content will be restored to the document. + +If a review is canceled, no resolved suggestions will be applied to the document. All suggestions, including any feedback provided during that review session, will remain stored in the model. + +== Initial setup + +To setup the {pluginname} plugin in the editor: + +* add `{plugincode}` to the `plugins` option in the editor configuration; +* add `{plugincode}` to the `toolbar` option in the editor configuration; + +For example: + +[source,js] +---- +tinymce.init({ + selector: 'textarea#suggestededits', // change this value according to your HTML + plugins: 'suggestededits', + toolbar: 'suggestededits', +}); +---- + +This configuration adds {pluginname} to the editor toolbar, enabling access to the plugin features. To fully utilize the plugin, additional configuration options must be provided. + +== Options + +The following configuration options affect the behavior of the {pluginname} plugin. + +include::partial$configuration/suggestededits_model.adoc[leveloffset=+1] + +include::partial$configuration/suggestededits_content.adoc[leveloffset=+1] + +include::partial$configuration/suggestededits_access.adoc[leveloffset=+1] + +include::partial$configuration/user_id.adoc[leveloffset=+1] + +include::partial$configuration/fetch_users.adoc[leveloffset=+1] + +Both the xref:#user_id[`+user_id+`] and xref:#fetch_users[`+fetch_users+`] options are required to configure the xref:userlookup.adoc[User Lookup API]. This API is used in the {pluginname} plugin to provide user information for each user who has made a change, allowing other users to see who made which suggestion. + +* The current user ID should be set with the xref:#user_id[`+user_id+`] option. The user ID should be a unique string that identifies the user, such as a username or an email address. +* The xref:#fetch_users[`+fetch_users+`] option is required to provide the name and avatar for users who have made suggestions. This option can be configured to fetch data from a backend service. The `+fetch_users+` function is given an array of user IDs and should return a promise, which resolves to an array containing data for the requested user IDs. + +include::partial$misc/plugin-toolbar-button-id-boilerplate.adoc[] + +include::partial$misc/plugin-menu-item-id-boilerplate.adoc[] + +== Commands + +The {pluginname} plugin provides the following {productname} commands. + +include::partial$commands/{plugincode}-cmds.adoc[] + +== Events + +The {pluginname} plugin provides the following events. + +include::partial$events/{plugincode}-events.adoc[] + +== APIs + +The {pluginname} plugin provides the following APIs. + +include::partial$plugin-apis/{plugincode}-apis.adoc[] + +//// +== Known Limitations + +[NOTE] +Since the comparison is made against the original version of the document, if there are unresolved changes by multiple authors affecting the same part, the most recent change will be used for comparison. The `hasChanges` API can be used to implement a safeguard that prevents further edits on a document with unresolved changes. + +//// \ No newline at end of file diff --git a/modules/ROOT/pages/userlookup.adoc b/modules/ROOT/pages/userlookup.adoc new file mode 100644 index 0000000000..c1890a3e0b --- /dev/null +++ b/modules/ROOT/pages/userlookup.adoc @@ -0,0 +1,203 @@ += User Lookup API +:navtitle: User Lookup +:description: TinyMCE User Lookup provides an API to identify the current user and retrieve user details via integration-defined methods. +:keywords: userLookup, fetchUsers, user_id, user management, collaborative editing +:apiname: User Lookup + +The {productname} {apiname} API allows you to retrieve and cache user details, such as names and avatars, and identify the current user within the editor. + +This is useful when building features that rely on user context, such as commenting, collaborative editing, or displaying user-specific information. The API provides built-in caching for efficient user data management, as well as fallback user data when none is provided. + +== Properties and Methods + +The {apiname} API serves two purposes, to identify the current user and to fetch user details for other users. These can be configured to suit your application through the xref:#user_id[`user_id`] and xref:#fetch_users[`fetch_users`] options respectively. + +The {apiname} API provides properties and methods through the `editor.userLookup` object, which is available after the editor is initialized. + +[cols="2,1,2",options="header"] +|=== +|API|Type|Return Type + +|xref:#userlookup-userid[`editor.userLookup.userId`]|Property|`string` +|xref:#userlookup-fetchusers[`editor.userLookup.fetchUsers(userIds)`]|Method|`Object>` +|=== + +[[userlookup-userid]] +=== `editor.userLookup.userId` + +Returns the current user's ID, set via the `user_id` configuration option. This is a string that uniquely identifies the user in the context of the editor, such as a username or an email address. If the `user_id` option is not set, it defaults to `'Anonymous'`. + +[[userlookup-fetchusers]] +=== `editor.userLookup.fetchUsers` + +Fetches and caches user information for the specified user IDs. This method accepts an array of user IDs and returns an object where each key is a user ID and each value is a Promise that resolves to the user object. If a user ID is not found, the Promise will reject with an error message. + +=== The `user` object + +The user is an `Object` that contains the following fields: + +[cols="1,1,1,3",options="header"] +|=== +| Field | Type | Required? | Description +| `id` | `string` | required | The unique string ID of the user. +| `name` | `string` | optional | The display name of the user. If not provided, defaults to the `id`. +| `avatar` | `string` | optional | The URL of the user's avatar image or data URI. If not provided, an auto-generated SVG using the user's initials will be used. +| `custom` | `Object` | optional | Additional metadata for the user. This can include any custom fields relevant to your application, such as roles or permissions. +|=== + +.Example user object: +[source,js] +---- +{ + id: 'user-1', // Required: User identifier + name: 'Jane Doe', // Optional: Display name (defaults to id) + avatar: 'https://...', // Optional: Avatar URL or data URI + custom: { // Optional: Additional metadata + role: 'admin', + department: 'engineering' + } +} +---- + +== Options + +The following configuration options affect the behavior of the {apiname} API. + +include::partial$configuration/user_id.adoc[leveloffset=+1] + +include::partial$configuration/fetch_users.adoc[leveloffset=+1] + +== Usage Examples + +=== Basic setup + +For a basic setup, configuring the `user_id` option allows you to identify the current user. The `fetch_users` option can be omitted, and {productname} will provide fallback user data. + +[source,js] +---- +tinymce.init({ + selector: '#editor', + user_id: 'alextaylor' +}); +---- + +=== Complete setup with `fetch_users` + +This example demonstrates how to simulate user lookup in-browser. In a production environment, the `fetch_users` function would typically make network requests to your backend API. + +.Example +[source,js] +---- +const userDb = { + 'janedoe': { + id: 'janedoe', + name: 'Jane Doe', + avatar: 'https://example.com/avatar/jane.png' + }, + 'johnsmith': { + id: 'johnsmith', + name: 'John Smith' + }, + 'alextaylor': { + id: 'alextaylor', + name: 'Alex Taylor', + avatar: 'https://example.com/avatar/alex.png' + } +}; + +tinymce.init({ + selector: '#editor', + user_id: 'alextaylor', + fetch_users: (userIds) => { + return Promise.all( + userIds.map( + (userId) => new Promise( + (resolve) => resolve(userDb[userId] || { id: userId }) // Return user data or a fallback object if not found + ) + ) + ) + }, +}); +---- + +NOTE: It is recommended to handle (or catch) promise rejections in the `fetch_users` function by providing fallback data. This will ensure that your application can gracefully handle cases where user data is not found or the network request fails. + +=== Basic usage + +Once the editor is initialized, you can access the current user ID and fetch user information: + +[source,js] +---- +editor.on('init', () => { + // Get the current user ID + const currentUserId = editor.userLookup.userId; + console.log('Current user:', currentUserId); + + // Fetch user information + const users = editor.userLookup.fetchUsers([ currentUserId ]); + + users[currentUserId].then((user) => { + console.log('Current user name:', user.name); + }); + + // Handle multiple users + Promise.all(Object.values(users)).then((allUsers) => { + allUsers.forEach((user) => { + console.log(`Fetched: ${user.name} (${user.id})`); + }); + }); +}); +---- + +=== Advanced usage with UI example + +This example shows how to create a toolbar button that displays the current user's information: + +[source,js] +---- +tinymce.init({ + selector: '#editor', + user_id: 'alextaylor', + setup: (editor) => { + editor.ui.registry.addButton('show-user', { + text: 'Show User', + onAction: () => { + const currentUser = editor.userLookup.userId; + const users = editor.userLookup.fetchUsers([ currentUser ]); + + users[currentUser].then((user) => { + editor.notificationManager.open({ + text: `Logged in as: ${user.name}`, + type: 'info' + }); + }); + } + }); + }, + toolbar: 'show-user' +}); +---- + +=== Fallback Behavior + +If no `fetch_users` function is configured, {productname} will return fallback users with auto-generated data: + +* `name` defaults to the user ID +* `avatar` is an auto-generated SVG using the first letter of the name + +This is useful for simpler setups or prototyping: + +[source,js] +---- +tinymce.init({ + selector: '#editor', + user_id: 'alextaylor' +}); + +editor.on('init', () => { + const users = editor.userLookup.fetchUsers(['alextaylor']); + users['alextaylor'].then((user) => { + console.log('Generated fallback user:', user); + }); +}); +---- diff --git a/modules/ROOT/partials/DEPRECATED/generic_8_userlookup.adoc b/modules/ROOT/partials/DEPRECATED/generic_8_userlookup.adoc new file mode 100644 index 0000000000..f9d4b1dd5b --- /dev/null +++ b/modules/ROOT/partials/DEPRECATED/generic_8_userlookup.adoc @@ -0,0 +1 @@ +IMPORTANT: This option has been deprecated in {productname} {productmajorversion} and may be removed in a future major {productname} release. Use a combination of `user_id` and `fetch_users` instead. | `xref:apis/tinymce.editor.userlookup.adoc[UserLookup]` diff --git a/modules/ROOT/partials/commands/suggestededits-cmds.adoc b/modules/ROOT/partials/commands/suggestededits-cmds.adoc new file mode 100644 index 0000000000..5ac738e8ff --- /dev/null +++ b/modules/ROOT/partials/commands/suggestededits-cmds.adoc @@ -0,0 +1,11 @@ +[cols="1,3",options="header"] +|=== +|Command |Description +|suggestededits |Toggles the Suggested Edits view. +|=== + +.Example +[source,js] +---- +tinymce.activeEditor.execCommand('suggestededits'); +---- diff --git a/modules/ROOT/partials/configuration/defaultmenuitems.adoc b/modules/ROOT/partials/configuration/defaultmenuitems.adoc index dd2c811ce9..eaf1063a98 100644 --- a/modules/ROOT/partials/configuration/defaultmenuitems.adoc +++ b/modules/ROOT/partials/configuration/defaultmenuitems.adoc @@ -5,7 +5,7 @@ tinymce.init({ menu: { file: { title: 'File', items: 'newdocument restoredraft | preview | importword exportpdf exportword | print | deleteallconversations' }, edit: { title: 'Edit', items: 'undo redo | cut copy paste pastetext | selectall | searchreplace' }, - view: { title: 'View', items: 'code revisionhistory | visualaid visualchars visualblocks | spellchecker | preview fullscreen | showcomments' }, + view: { title: 'View', items: 'code suggestededits revisionhistory | visualaid visualchars visualblocks | spellchecker | preview fullscreen | showcomments' }, insert: { title: 'Insert', items: 'image link media addcomment pageembed codesample inserttable | math | charmap emoticons hr | pagebreak nonbreaking anchor tableofcontents | insertdatetime' }, format: { title: 'Format', items: 'bold italic underline strikethrough superscript subscript codeformat | styles blocks fontfamily fontsize align lineheight | forecolor backcolor | language | removeformat' }, tools: { title: 'Tools', items: 'spellchecker spellcheckerlanguage | a11ycheck code wordcount' }, diff --git a/modules/ROOT/partials/configuration/fetch_users.adoc b/modules/ROOT/partials/configuration/fetch_users.adoc new file mode 100644 index 0000000000..e904ba7ec9 --- /dev/null +++ b/modules/ROOT/partials/configuration/fetch_users.adoc @@ -0,0 +1,55 @@ +[[fetch_users]] +== `fetch_users` + +A **required callback function** that fetches user data. This function is called with an array of user IDs and should return a `Promise` that resolves to an array of user objects. The callback is used by the xref:userlookup.adoc[`UserLookup`] API. If the returned array does not include all requested user IDs, promises for the missing users will be rejected with a "User \{id} not found" error. + +*Type:* `Function` + +*Parameters:* +- `ids` (`Array`): An array of user IDs to fetch. + +*Returns:* +- `Promise>`: A promise that resolves to an array of user objects. + +.Example: using `fetch_users` option +[source,javascript] +---- +tinymce.init({ + selector: '#editor', + user_id: 'alextaylor', + fetch_users: (userIds) => Promise.all(userIds + .map((userId) => + fetch(`/users/${userId}`) // Fetch user data from the server + .then((response) => response.json()) + .catch(() => ({ id: userId })) // Still return a valid user object even if the fetch fails + )), +}); +---- + +.Example: returning user array with validation +[source,javascript] +---- +tinymce.init({ + selector: '#editor', + user_id: 'alextaylor', + fetch_users: async (userIds) => { + const users = await fetch('/users', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ userIds }) + }) + .then((response) => response.json()) + .catch(() => + userIds.map((userId) => + ({ id: userId }) // Still returns valid users even if the fetch fails + ) + ); + + return userIds.map( + (userId) => + users.find((user) => user.id === userId) + || ({ id: userId }) // Still returns a valid user even if it wasn't returned from the server + ) + } +}); +---- \ No newline at end of file diff --git a/modules/ROOT/partials/configuration/icon_list.adoc b/modules/ROOT/partials/configuration/icon_list.adoc index c8a777f63d..69835f6847 100644 --- a/modules/ROOT/partials/configuration/icon_list.adoc +++ b/modules/ROOT/partials/configuration/icon_list.adoc @@ -37,11 +37,13 @@ | `+checklist-rtl+` | image:icons/checklist-rtl.svg[checklist-rtl.svg] | `+checklist-rtl.svg+` | `+checklist+` | image:icons/checklist.svg[checklist.svg] | `+checklist.svg+` | `+checkmark+` | image:icons/checkmark.svg[checkmark.svg] | `+checkmark.svg+` +| `+checkmark-filled+` | image:icons/checkmark-filled.svg[checkmark-filled.svg] | `+checkmark-filled.svg+` | `+chevron-down+` | image:icons/chevron-down.svg[chevron-down.svg] | `+chevron-down.svg+` | `+chevron-left+` | image:icons/chevron-left.svg[chevron-left.svg] | `+chevron-left.svg+` | `+chevron-right+` | image:icons/chevron-right.svg[chevron-right.svg] | `+chevron-right.svg+` | `+chevron-up+` | image:icons/chevron-up.svg[chevron-up.svg] | `+chevron-up.svg+` | `+close+` | image:icons/close.svg[close.svg] | `+close.svg+` +| `+close-filled+` | image:icons/close-filled.svg[close-filled.svg] | `+close-filled.svg+` | `+code-sample+` | image:icons/code-sample.svg[code-sample.svg] | `+code-sample.svg+` | `+color-levels+` | image:icons/color-levels.svg[color-levels.svg] | `+color-levels.svg+` | `+color-picker+` | image:icons/color-picker.svg[color-picker.svg] | `+color-picker.svg+` @@ -71,6 +73,7 @@ | `+export-word+` | image:icons/export-word.svg[export-word.svg] | `+export-word.svg+` | `+exposure+` | image:icons/exposure.svg[exposure.svg] | `+exposure.svg+` | `+fb+` | image:icons/fb.svg[fb.svg] | `+fb.svg+` +| `+feedback+` | image:icons/feedback.svg[feedback.svg] | `+feedback.svg+` | `+fill+` | image:icons/fill.svg[fill.svg] | `+fill.svg+` | `+flickr+` | image:icons/flickr.svg[flickr.svg] | `+flickr.svg+` | `+flip-horizontally+` | image:icons/flip-horizontally.svg[flip-horizontally.svg] | `+flip-horizontally.svg+` @@ -176,6 +179,8 @@ | `+strike-through+` | image:icons/strike-through.svg[strike-through.svg] | `+strike-through.svg+` | `+subscript+` | image:icons/subscript.svg[subscript.svg] | `+subscript.svg+` | `+superscript+` | image:icons/superscript.svg[superscript.svg] | `+superscript.svg+` +| `+suggestededits+` | image:icons/suggestededits.svg[suggestededits.svg] | `+suggestededits.svg+` +| `+suggestededits-badge+` | image:icons/suggestededits-badge.svg[suggestededits-badge.svg] | `+suggestededits-badge.svg+` | `+table-caption+` | image:icons/table-caption.svg[table-caption.svg] | `+table-caption.svg+` | `+table-cell-classes+` | image:icons/table-cell-classes.svg[table-cell-classes.svg] | `+table-cell-classes.svg+` | `+table-cell-properties+` | image:icons/table-cell-properties.svg[table-cell-properties.svg] | `+table-cell-properties.svg+` diff --git a/modules/ROOT/partials/configuration/revisionhistory_author.adoc b/modules/ROOT/partials/configuration/revisionhistory_author.adoc index 5e9d867c4e..35978bbf7d 100644 --- a/modules/ROOT/partials/configuration/revisionhistory_author.adoc +++ b/modules/ROOT/partials/configuration/revisionhistory_author.adoc @@ -1,6 +1,8 @@ [[revisionhistory_author]] == `revisionhistory_author` +include::partial$DEPRECATED/generic_8_userlookup.adoc[] + This option configures the author for the `initial` and `draft` revisions. *Type:* xref:#author[Author] `+Object+` diff --git a/modules/ROOT/partials/configuration/revisionhistory_fetch_revision.adoc b/modules/ROOT/partials/configuration/revisionhistory_fetch_revision.adoc index d729459112..56116855b8 100644 --- a/modules/ROOT/partials/configuration/revisionhistory_fetch_revision.adoc +++ b/modules/ROOT/partials/configuration/revisionhistory_fetch_revision.adoc @@ -65,15 +65,15 @@ tinymce.init({ toolbar: 'revisionhistory', revisionhistory_fetch: () => Promise.resolve(lightRevisions), revisionhistory_fetch_revision: (_editor, revision) => new Promise((resolve) => { - let newRevision = null; - for (let i = 0; i < revisions.length; i++) { - const temp = revisions[i]; - if (temp.revisionId === revision.revisionId) { - newRevision = temp; - break; + let newRevision = null; + for (let i = 0; i < revisions.length; i++) { + const temp = revisions[i]; + if (temp.revisionId === revision.revisionId) { + newRevision = temp; + break; + } } - } - resolve(newRevision); + resolve(newRevision); }) }); ---- diff --git a/modules/ROOT/partials/configuration/suggestededits_access.adoc b/modules/ROOT/partials/configuration/suggestededits_access.adoc new file mode 100644 index 0000000000..f011afb87e --- /dev/null +++ b/modules/ROOT/partials/configuration/suggestededits_access.adoc @@ -0,0 +1,44 @@ +[[suggestededits_access]] +== `suggestededits_access` + +The `suggestededits_access` option determines the level of access a user has to the {pluginname} view. This setting is crucial for controlling who can accept or reject suggestions, add feedback, or view suggestions in read-only mode. The `suggestededits_access` option does not control access to the editor content, however when used in conjunction with the xref:editor-important-options#readonly[`readonly`] option it allows for fine-grained control over user permissions. + +When set to: + +* `'full'`: The user has full access to the {pluginname} view, with permission to accept or reject suggestions. +* `'feedback'`: The user has access to the {pluginname} view, with permission to add feedback to suggestions. +* `'read'`: The user has read-only access to the {pluginname} view. +* `'none'`: The user has no access to the {pluginname} view. + +*Type:* `+String+` + +*Possible Values*: `'full'`, `'feedback'`, `'read'`, `'none'` + +*Default Value*: `'full'` + +.Example: `suggestededits_access: 'feedback'` +[source,javascript] +---- +tinymce.init({ + selector: 'textarea', // Change this value according to your HTML + plugins: 'suggestededits', + toolbar: 'suggestededits', + suggestededits_access: 'feedback', // Change this value to set the {pluginname} view permissions + readonly: false // Set to true to restrict editing +}); +---- + +liveDemo::suggestededits-access-feedback[] + +.Example: `suggestededits_access: 'read'` +[source,javascript] +---- +tinymce.init({ + selector: 'textarea', // Change this value according to your HTML + plugins: 'suggestededits', + toolbar: 'suggestededits', + suggestededits_access: 'read', // Change this value to set the {pluginname} view permissions + readonly: false // Set to true to restrict editing +}); +---- +liveDemo::suggestededits-access-read[] \ No newline at end of file diff --git a/modules/ROOT/partials/configuration/suggestededits_content.adoc b/modules/ROOT/partials/configuration/suggestededits_content.adoc new file mode 100644 index 0000000000..76ffd87f55 --- /dev/null +++ b/modules/ROOT/partials/configuration/suggestededits_content.adoc @@ -0,0 +1,29 @@ +[[suggestededits_content]] +== `suggestededits_content` + +The `suggestededits_content` option controls whether the content is loaded from the editor or the `suggestededits_model` model, allowing you to define the source of truth for the document. In either case, if a model is not provided, the plugin will generate a new model from the initial editor content. + +When set to: + +* `'html'`: the editor loads content normally, and the plugin synchronises the model with the content. This simplifies the configuration of {pluginname} in an existing integration, by attaching the {pluginname} model as an additional field alongside the editor content. +* `'model'`: the editor uses the `suggestededits_model` option to generate the initial content, ignoring any pre-existing content in the editor. With this configuration, you only need to store and provide the model, simplifying the integration of the {pluginname} plugin. + +NOTE: You are responsible for saving the model and providing it on the next load using the `suggestededits_model` option. + +*Type:* `+String+` + +*Possible Values*: `'html'`, `'model'` + +*Default value:* `'html'` + +.Example: using `suggestededits_content` +[source,js] +---- +tinymce.init({ + selector: 'textarea', // Change this value according to your HTML + plugins: 'suggestededits', + toolbar: 'suggestededits', + suggestededits_model, // Load the saved model into the editor + suggestededits_content: 'model' // Set to 'model' if you want to load the initial content from the `suggestededits_model` option +}); +---- diff --git a/modules/ROOT/partials/configuration/suggestededits_model.adoc b/modules/ROOT/partials/configuration/suggestededits_model.adoc new file mode 100644 index 0000000000..6e6b1a70aa --- /dev/null +++ b/modules/ROOT/partials/configuration/suggestededits_model.adoc @@ -0,0 +1,22 @@ +[[suggestededits_model]] +== `suggestededits_model` + +The `suggestededits_model` option loads an existing model into the {pluginname} plugin. This model contains all current suggested edits and is used to maintain continuity across sessions. If a model is not provided, the plugin generates a new model from the initial editor content. + +*Type:* `+Object+` + +.Example: using `{plugincode}_model` + +[source,js] +---- +await fetch(`/models/${documentId}`) + .then((response) => response.json()) + .then((model) => { + tinymce.init({ + selector: 'textarea', // Change this value according to your HTML + plugins: 'suggestededits', + toolbar: 'suggestededits', + suggestededits_model: model // Load the saved model into the editor + }); + }); +---- diff --git a/modules/ROOT/partials/configuration/tinycomments_author.adoc b/modules/ROOT/partials/configuration/tinycomments_author.adoc index 2fd8b7f9a1..cd88b59723 100644 --- a/modules/ROOT/partials/configuration/tinycomments_author.adoc +++ b/modules/ROOT/partials/configuration/tinycomments_author.adoc @@ -1,6 +1,8 @@ [[tinycomments_author]] == `+tinycomments_author+` +include::partial$DEPRECATED/generic_8_userlookup.adoc[] + This option sets the author id to be used when creating or replying to comments. *Type:* `+String+` diff --git a/modules/ROOT/partials/configuration/tinycomments_author_avatar.adoc b/modules/ROOT/partials/configuration/tinycomments_author_avatar.adoc index a36c4b990c..e9cb63768d 100644 --- a/modules/ROOT/partials/configuration/tinycomments_author_avatar.adoc +++ b/modules/ROOT/partials/configuration/tinycomments_author_avatar.adoc @@ -1,6 +1,8 @@ [[tinycomments_author_avatar]] == `+tinycomments_author_avatar+` +include::partial$DEPRECATED/generic_8_userlookup.adoc[] + _Optional_: This option sets the URL for the author's avatar to be used when creating or replying to comments. If this option is omitted, a generated avatar will be used instead. The avatar, if provided: * will be scaled to a 36px diameter circle; and diff --git a/modules/ROOT/partials/configuration/tinycomments_author_name.adoc b/modules/ROOT/partials/configuration/tinycomments_author_name.adoc index e13792222b..d1e459b17c 100644 --- a/modules/ROOT/partials/configuration/tinycomments_author_name.adoc +++ b/modules/ROOT/partials/configuration/tinycomments_author_name.adoc @@ -1,6 +1,8 @@ [[tinycomments_author_name]] == `+tinycomments_author_name+` +include::partial$DEPRECATED/generic_8_userlookup.adoc[] + _Optional_: This option sets the author's display name to be used when creating or replying to comments. If this option is omitted, the author `+id+` is used instead. *Type:* `+String+` diff --git a/modules/ROOT/partials/configuration/tinycomments_can_delete.adoc b/modules/ROOT/partials/configuration/tinycomments_can_delete.adoc index d4b795e43c..b47abe902d 100644 --- a/modules/ROOT/partials/configuration/tinycomments_can_delete.adoc +++ b/modules/ROOT/partials/configuration/tinycomments_can_delete.adoc @@ -1,7 +1,7 @@ [[tinycomments_can_delete]] == `+tinycomments_can_delete+` -_Optional_: This option sets the author permissions for _deleting comment conversations_. If the `+tinycomments_can_delete+` option is not included, the current author (`+tinycomments_author+`) cannot delete comment conversations created by other authors. +_Optional_: This option sets the author permissions for _deleting comment conversations_. If the `+tinycomments_can_delete+` option is **not included**, the current author (`+user_id+`) cannot delete comment conversations created by other authors. *Type:* `+Function+` @@ -9,7 +9,7 @@ _Optional_: This option sets the author permissions for _deleting comment conver [source,js] ---- (req, done, fail) => { - const allowed = req.comments.length > 0 && req.comments[0].author === ; + const allowed = req.comments.length > 0 && req.comments[0].author === ; done({ canDelete: allowed }); @@ -30,7 +30,7 @@ tinymce.init({ plugins: 'tinycomments', toolbar: 'addcomment showcomments', tinycomments_mode: 'embedded', - tinycomments_author: currentAuthor, + user_id: currentAuthor, tinycomments_can_delete: (req, done, fail) => { const allowed = req.comments.length > 0 && req.comments[0].author === currentAuthor; done({ diff --git a/modules/ROOT/partials/configuration/tinycomments_can_delete_comment.adoc b/modules/ROOT/partials/configuration/tinycomments_can_delete_comment.adoc index 3d7f0e6be5..dbd7cf1cd6 100644 --- a/modules/ROOT/partials/configuration/tinycomments_can_delete_comment.adoc +++ b/modules/ROOT/partials/configuration/tinycomments_can_delete_comment.adoc @@ -1,7 +1,7 @@ [[tinycomments_can_delete_comment]] == `+tinycomments_can_delete_comment+` -_Optional_: This option sets the author permissions for _deleting comments_. If the `+tinycomments_can_delete_comment+` option is not included, the current author (`+tinycomments_author+`) **cannot** delete comments added by other authors. +_Optional_: This option sets the author permissions for _deleting comments_. If the `+tinycomments_can_delete_comment+` option is **not included**, the current author (`+user_id+`) **cannot** delete comments added by other authors. *Type:* `+Function+` @@ -9,7 +9,7 @@ _Optional_: This option sets the author permissions for _deleting comments_. If [source,js] ---- (req, done, fail) => { - const allowed = req.comment.author === ; + const allowed = req.comment.author === ; done({ canDelete: allowed }); @@ -30,7 +30,7 @@ tinymce.init({ plugins: 'tinycomments', toolbar: 'addcomment showcomments', tinycomments_mode: 'embedded', - tinycomments_author: currentAuthor, + user_id: currentAuthor, tinycomments_can_delete_comment: (req, done, fail) => { const allowed = req.comment.author === currentAuthor; done({ diff --git a/modules/ROOT/partials/configuration/tinycomments_can_edit_comment.adoc b/modules/ROOT/partials/configuration/tinycomments_can_edit_comment.adoc index dcf1339e64..1b0d7d2e0b 100644 --- a/modules/ROOT/partials/configuration/tinycomments_can_edit_comment.adoc +++ b/modules/ROOT/partials/configuration/tinycomments_can_edit_comment.adoc @@ -1,7 +1,7 @@ [[tinycomments_can_edit_comment]] == `+tinycomments_can_edit_comment+` -_Optional_: This option sets the author permissions for _editing comments_. If the `+tinycomments_can_edit_comment+` option is not included, the current author (`+tinycomments_author+`) cannot edit comments added by other authors. +_Optional_: This option sets the author permissions for _editing comments_. If the `+tinycomments_can_edit_comment+` option is **not included**, the current author (`+user_id+`) cannot edit comments added by other authors. *Type:* `+Function+` @@ -9,7 +9,7 @@ _Optional_: This option sets the author permissions for _editing comments_. If t [source,js] ---- (req, done, fail) => { - const allowed = req.comment.author === ; + const allowed = req.comment.author === ; done({ canEdit: allowed }); @@ -29,7 +29,7 @@ tinymce.init({ plugins: 'tinycomments', toolbar: 'addcomment showcomments', tinycomments_mode: 'embedded', - tinycomments_author: currentAuthor, + user_id: currentAuthor, tinycomments_can_edit_comment: (req, done, fail) => { const allowed = req.comment.author === currentAuthor; done({ diff --git a/modules/ROOT/partials/configuration/tinycomments_can_resolve.adoc b/modules/ROOT/partials/configuration/tinycomments_can_resolve.adoc index 491cb7ba97..5873857e0b 100644 --- a/modules/ROOT/partials/configuration/tinycomments_can_resolve.adoc +++ b/modules/ROOT/partials/configuration/tinycomments_can_resolve.adoc @@ -16,7 +16,7 @@ tinymce.init({ plugins: 'tinycomments', toolbar: 'addcomment showcomments', tinycomments_mode: 'embedded', - tinycomments_author: currentAuthor, + user_id: currentAuthor, tinycomments_can_resolve: (req, done, fail) => { const allowed = req.comments.length > 0 && req.comments[0].author === currentAuthor; done({ diff --git a/modules/ROOT/partials/configuration/tinycomments_fetch_author_info.adoc b/modules/ROOT/partials/configuration/tinycomments_fetch_author_info.adoc index 077409d53d..801ebe839d 100644 --- a/modules/ROOT/partials/configuration/tinycomments_fetch_author_info.adoc +++ b/modules/ROOT/partials/configuration/tinycomments_fetch_author_info.adoc @@ -1,6 +1,8 @@ [[tinycomments_fetch_author_info]] == `+tinycomments_fetch_author_info+` +include::partial$DEPRECATED/generic_8_userlookup.adoc[] + The {pluginname} plugin uses the `+tinycomments_fetch_author_info+` callback function to provide author details for comments displayed in the editor. This function is called whenever a comment is rendered, and it allows the editor to display the author’s name and avatar based on the current user. diff --git a/modules/ROOT/partials/configuration/tinycomments_mentions_enabled.adoc b/modules/ROOT/partials/configuration/tinycomments_mentions_enabled.adoc index 45e0df05ce..23d0a186fd 100644 --- a/modules/ROOT/partials/configuration/tinycomments_mentions_enabled.adoc +++ b/modules/ROOT/partials/configuration/tinycomments_mentions_enabled.adoc @@ -13,14 +13,29 @@ The {pluginname} plugin offers the `+tinycomments_mentions_enabled+` option to c [source,js] ---- +const userDb = { + 'johnsmith': { + id: 'johnsmith', + name: 'John Smith' + }, +}; + tinymce.init({ selector: 'textarea', // change this value according to your html plugins: 'tinycomments mentions', toolbar: 'addcomment showcomments', tinycomments_mentions_enabled: true, tinycomments_mode: 'embedded', - tinycomments_author: 'johnsmith', - tinycomments_author_name: 'John Smith', + user_id: 'johnsmith', + fetch_users: (userIds) => { + return Promise.all( + userIds.map( + (userId) => new Promise( + (resolve) => resolve(userDb[userId] || { id: userId }) + ) + ) + ) + }, mentions_fetch, mentions_menu_complete, mentions_menu_hover, diff --git a/modules/ROOT/partials/configuration/user_id.adoc b/modules/ROOT/partials/configuration/user_id.adoc new file mode 100644 index 0000000000..2bcc3552bf --- /dev/null +++ b/modules/ROOT/partials/configuration/user_id.adoc @@ -0,0 +1,17 @@ +[[user_id]] +== `+user_id+` + +This option sets the unique identifier for the current user in the editor. It is used in the the xref:userlookup.adoc[`UserLookup`] API. + +*Type:* `+String+` + +*Default value:* `'Anonymous'` + +.Example: using `user_id` option +[source,javascript] +---- +tinymce.init({ + selector: 'textarea', // Change this value according to your HTML + user_id: 'alextaylor' // replace this with a unique string to identify the user +}); +---- \ No newline at end of file diff --git a/modules/ROOT/partials/events/suggestededits-events.adoc b/modules/ROOT/partials/events/suggestededits-events.adoc new file mode 100644 index 0000000000..094c76c1fc --- /dev/null +++ b/modules/ROOT/partials/events/suggestededits-events.adoc @@ -0,0 +1,10 @@ +The following events are provided by the xref:{plugincode}.adoc[{pluginname} plugin]. + +[cols="2,1,3",options="header"] +|=== +|Name |Data |Description +|SuggestedEditsBeginReview |N/A |The Suggested Edits view has opened. +|SuggestedEditsReviewComplete |N/A |A review in the Suggested Edits view has been completed. +|SuggestedEditsReviewCancelled |N/A |A review in the Suggested Edits view was cancelled. +|SuggestedEditsHasChangesUpdate |`+hasChanges+` |The Suggested Edits model is updated. The `+hasChanges+` data is a boolean value indicating whether there are suggestions to review. +|=== \ No newline at end of file diff --git a/modules/ROOT/partials/index-pages/premium-plugins.adoc b/modules/ROOT/partials/index-pages/premium-plugins.adoc index 3d76fe8cc5..75cc78e56a 100644 --- a/modules/ROOT/partials/index-pages/premium-plugins.adoc +++ b/modules/ROOT/partials/index-pages/premium-plugins.adoc @@ -177,6 +177,12 @@ xref:autocorrect.adoc[Spelling Autocorrect] Add autocorrection of common typos and spelling errors as well as capitalization correction to {productname}. +a| +[.lead] +xref:suggestededits.adoc[Suggested Edits] + +Collaborate with multiple users on a document by drafting edits, providing feedback, and reviewing suggestions. + a| [.lead] xref:advanced-templates.adoc[Templates] @@ -202,6 +208,4 @@ Cloud-based file and image management for {productname}. // 2. Prepend the inline comment markup to this element // when the number of cells in the table is even. a| - |=== - diff --git a/modules/ROOT/partials/menu-item-ids/suggestededits-menu-items.adoc b/modules/ROOT/partials/menu-item-ids/suggestededits-menu-items.adoc new file mode 100644 index 0000000000..d45e2420b5 --- /dev/null +++ b/modules/ROOT/partials/menu-item-ids/suggestededits-menu-items.adoc @@ -0,0 +1,6 @@ +[cols="1,1,2",options="header"] +|=== +|Menu item identifier |xref:menus-configuration-options.adoc#example-the-tinymce-default-menu-items[Default Menu Location] |Description + +|`suggestededits` |View |Opens the Suggested Edits view. +|=== \ No newline at end of file diff --git a/modules/ROOT/partials/misc/admon-requires-8.0v.adoc b/modules/ROOT/partials/misc/admon-requires-8.0v.adoc new file mode 100644 index 0000000000..9160a25fc7 --- /dev/null +++ b/modules/ROOT/partials/misc/admon-requires-8.0v.adoc @@ -0,0 +1 @@ +NOTE: This feature is only available for {productname} 8.0 and later. diff --git a/modules/ROOT/partials/plugin-apis/suggestededits-apis.adoc b/modules/ROOT/partials/plugin-apis/suggestededits-apis.adoc new file mode 100644 index 0000000000..5801412599 --- /dev/null +++ b/modules/ROOT/partials/plugin-apis/suggestededits-apis.adoc @@ -0,0 +1,163 @@ +[cols="1,1,4",options="header"] +|=== +|Name |Arguments |Description +|getModel |N/A |Returns a JSON object representing the current model of the document. +|setModel | `+Object+` | Sets the current model of the document. +|resetModel |N/A |Generates a model from the current content and sets it as the current model, clearing all suggestions. +|hasChanges |N/A |Returns a boolean value indicating whether the document contains any suggested edits. +|=== + +.Examples + +[source,js] +---- +// Get the current model of the document +tinymce.activeEditor.plugins.suggestededits.getModel(); + +// Set current model of the document +tinymce.activeEditor.plugins.suggestededits.setModel(model); + +// Reset the model to the current content, clearing all suggestions +tinymce.activeEditor.plugins.suggestededits.resetModel(); + +// Check if document contains changes to be reviewed +tinymce.activeEditor.plugins.suggestededits.hasChanges(); +---- + +[[get_model]] +.`getModel` Example + +This example demonstrates how to submit the current document and model to a server, to ensure they are saved synchronously. The current model is retrieved using the `getModel` API. + +[source,js] +---- +tinymce.init({ + selector: 'textarea#suggestededits', // change this value according to your HTML + plugins: 'suggestededits', + toolbar: 'suggestededits save', + suggestededits_model, + setup: (editor) => { + editor.ui.registry.addButton('save', { + text: 'Save', + onAction: () => { + // Get the current content of the editor + const content = editor.getContent(); + + // Get the current model of the document + const model = editor.plugins.suggestededits.getModel(); + + fetch(`/api/documents/${documentId}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: content, + }) + .then((response) => response.json()) + .then((data) => console.log('Document saved:', data)) + .catch((error) => console.error('Error saving document:', error)); + + // Save the model to the server + fetch(`/api/models/${documentId}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(model), + }) + .then((response) => response.json()) + .then((data) => console.log('Model saved:', data)) + .catch((error) => console.error('Error saving model:', error)); + } + }); + } +}); + +---- + +[[set_model]] +.`setModel` Example + +This example demonstrates how to set the model and the document in the editor, after fetching them from a server. The `setModel` method sets the current model of the document and the editor content, as generated from that model. + +[source,js] +---- +tinymce.init({ + selector: 'textarea#suggestededits', // change this value according to your HTML + plugins: 'suggestededits', + toolbar: 'suggestededits', + setup: (editor) => { + editor.on('init', () => { + // Fetch and set the suggested edits model + fetch(`/api/models/${documentId}`) + .then((response) => response.json()) + .then((model) => { + editor.plugins.suggestededits.setModel(model); + }) + .catch((error) => console.error(`Error fetching model for ${documentId}:`, error)); + }); + } +}); +---- + +[[reset_model]] +.`resetModel` Example + +This example demonstrates how to reset the model to the current content of the editor, clearing all suggestions. The `resetModel` method generates a new model from the current content and sets it as the current model. + +[source,js] +---- +tinymce.init({ + selector: 'textarea#suggestededits', // change this value according to your HTML + plugins: 'suggestededits', + toolbar: 'suggestededits clearsuggestions', + setup: (editor) => { + editor.ui.registry.addButton('clearsuggestions', { + text: 'Clear Suggestions', + onAction: () => { + // Reset the model to the current content, clearing all suggestions + editor.plugins.suggestededits.resetModel(); + + editor.notificationManager.open({ + text: 'All suggestions have been cleared.', + type: 'info', + timeout: 5000, + }); + } + }); + } +}); +---- + +[[has_changes]] +.`hasChanges` Example + +This example demonstrates how to check if there are any changes in the document that need to be reviewed before saving. The `hasChanges` method is used to determine if there are any unreviewed edits. + +[source,js] +---- +tinymce.init({ + selector: 'textarea#suggestededits', // change this value according to your HTML + plugins: 'suggestededits', + toolbar: 'suggestededits', + setup: (editor) => { + editor.ui.registry.addButton('save', { + text: 'Save', + onAction: () => { + // Get the current model of the document + const hasChanges = editor.plugins.suggestededits.hasChanges(); + + if (hasChanges) { + editor.notificationManager.open({ + text: 'There are changes to be reviewed.', + type: 'warning', + timeout: 5000, + }); + } else { + // Insert save logic here + } + } + }); + } +}); +---- diff --git a/modules/ROOT/partials/plugins/comments-open-sidebar.adoc b/modules/ROOT/partials/plugins/comments-open-sidebar.adoc index 03cbc525aa..ce46de407e 100644 --- a/modules/ROOT/partials/plugins/comments-open-sidebar.adoc +++ b/modules/ROOT/partials/plugins/comments-open-sidebar.adoc @@ -30,7 +30,7 @@ tinymce.init({ selector: 'textarea', // change this value according to your html plugins: 'tinycomments', tinycomments_mode: 'embedded', - tinycomments_author: currentAuthor, + user_id: currentAuthor, tinycomments_can_resolve: canResolveCommentsCallback, sidebar_show: 'showcomments' }); diff --git a/modules/ROOT/partials/toolbar-button-ids/suggestededits-toolbar-buttons.adoc b/modules/ROOT/partials/toolbar-button-ids/suggestededits-toolbar-buttons.adoc new file mode 100644 index 0000000000..83f8b4fb4c --- /dev/null +++ b/modules/ROOT/partials/toolbar-button-ids/suggestededits-toolbar-buttons.adoc @@ -0,0 +1,7 @@ +[cols="1,3",options="header"] +|=== +|Toolbar button identifier |Description + +|`+suggestededits+` |Opens the Review edits view. +|`+suggestededits-label+` |Opens the Review edits view (this button uses text instead of an icon). +|=== \ No newline at end of file