diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..7a7b33aa1dc7083e9536ca09bc4b96fddf7b1f66 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +package-lock.json +node_modules diff --git a/README.md b/README.md index 4d22d1e44e335dfd838a0befa158c9e5a0fde60b..606f2c835ce469ecbeb684efb25b4467eae5ca28 100644 --- a/README.md +++ b/README.md @@ -17,3 +17,8 @@ drush en aidmi - API Key Input Method: API key in Settings or File Path - File Path is recommended. Make sure you save the file outside of web/ (Example on page) - API Instructions: Default text provided that is sent to AI for image description. + +## Development of CKEditor5 Button + +npm install +npm run build diff --git a/aidmi.ckeditor5.yml b/aidmi.ckeditor5.yml new file mode 100644 index 0000000000000000000000000000000000000000..ad0d4974ecc00673b20ac0a9ef9077a1cc0fd5a9 --- /dev/null +++ b/aidmi.ckeditor5.yml @@ -0,0 +1,15 @@ +aidmi_aidmi: + ckeditor5: + plugins: + - aidmiPlugin.AIDmi + drupal: + label: AI, describe my image! + toolbar_items: + aidmi: + label: AI, describe my image! + library: aidmi/aidmi_ckeditor + elements: + - <h2> + - <h2 class="aidmi-title"> + - <div> + - <div class="aidmi-description"> diff --git a/aidmi.libraries.yml b/aidmi.libraries.yml index 3c30b136317a7f68b6efb96312fbc0d635f332d0..2f11e72d2ce1a6f4d645fc3496988a259250332f 100644 --- a/aidmi.libraries.yml +++ b/aidmi.libraries.yml @@ -3,8 +3,12 @@ aidmi_ckeditor: theme: css/aidmi.css: {} js: - js/aidmi.ckeditor.js: { preprocess: false } - js/aidmi.dialog.js: { preprocess: false } + js/build/aidmiPlugin.js: { preprocess: false, minified: true } + # js/aidmi.ckeditor.js: { preprocess: false } + # js/aidmi.dialog.js: { preprocess: false } dependencies: - core/ckeditor5 + - core/drupal.dialog.ajax + - core/jquery + - core/once \ No newline at end of file diff --git a/aidmi.module b/aidmi.module index 3c7b5bea32a3ce2a9dc961b049c3d09cbca18dfd..e19114d4d2a5c12a2ec9b9e88ff59f1f1daa00a0 100644 --- a/aidmi.module +++ b/aidmi.module @@ -3,11 +3,6 @@ use Drupal\editor\Entity\Editor; use Drupal\ckeditor5\Plugin\CKEditor5PluginDefinition; -/** - * @file - * AIDMI module. - */ - /** * Implements hook_help(). * @@ -19,11 +14,3 @@ function aidmi_help($route_name, \Drupal\Core\Routing\RouteMatchInterface $route return '<p>' . t('The AIDmi module allows users to upload images and get 508-compliant image descriptions generated by AI.') . '</p>'; } } - -/** - * Implements hook_page_attachments() to add JS to CKEditor 5 pages. - */ -function aidmi_page_attachments(array &$attachments) { - // Attach the JS library if CKEditor 5 is active. - $attachments['#attached']['library'][] = 'aidmi/aidmi_ckeditor'; -} diff --git a/aidmi.routing.yml b/aidmi.routing.yml index 5eb392acd13ff9c520a775491291052000461df4..947661737127b4e344ec3a6ea4868c711b2150c5 100644 --- a/aidmi.routing.yml +++ b/aidmi.routing.yml @@ -16,3 +16,13 @@ aidmi.describe_image_ajax: fid: \d+ options: _format: 'json' + +aidmi.describe_content_ajax: + path: '/admin/aidmi/describe-content-ajax' + defaults: + _controller: 'aidmi.aidmicontroller:analyzeContentAjax' + _title: 'AI, describe my image! (AIDmi)' + requirements: + _permission: 'access content' + options: + _format: 'json' \ No newline at end of file diff --git a/css/aidmi.css b/css/aidmi.css index d1fbeb1c8fc09975966d2ca869ef77b6ed6ce754..b1ae4ed21a66aec866d5e60cc0d7345ccfb1da48 100644 --- a/css/aidmi.css +++ b/css/aidmi.css @@ -19,6 +19,45 @@ margin-bottom: 10px; } +.aidmi-dialog-image { + max-width: 100%; + max-height: 400px; +} + +.aidmi-highlight-missing { + border: 2px solid red; + padding: 10px; + margin: 10px 0; +} + .aidmi-dialog-textarea { - resize:vertical; + resize: vertical; + width: 100%; +} + +.ckeditor5-toolbar-button-aidmi { + background-image: url(../icons/aidmi.svg); +} + +/* CKEditor5 AIDmi icon */ +.aidmi-icon-str1 { + stroke:#2D2A2B; + stroke-width:0.83; + stroke-miterlimit:2.61313; +} +.aidmi-icon-str0 { + stroke:black; + stroke-width:0.83; + stroke-miterlimit:2.61313; +} +.aidmi-icon-fil2 { + fill:none; +} +.aidmi-icon-fil0 { + fill:none; + fill-rule:nonzero; } +.aidmi-icon-fil1 { + fill:black; + fill-rule:nonzero; +} \ No newline at end of file diff --git a/icons/aidmi.svg b/icons/aidmi.svg new file mode 100644 index 0000000000000000000000000000000000000000..41e7f903393e70952e75088d92865c53626e7321 --- /dev/null +++ b/icons/aidmi.svg @@ -0,0 +1,12 @@ +<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" width="20px" height="20px" version="1.1" style="shape-rendering:geometricPrecision; text-rendering:geometricPrecision; image-rendering:optimizeQuality; fill-rule:evenodd; clip-rule:evenodd" +viewBox="0 0 20 20" + xmlns:xlink="http://www.w3.org/1999/xlink"> + <g id="aidmi-icon-layer"> + <metadata id="aidmi-icon"/> + <path class="aidmi-icon-fil0 aidmi-icon-str0" d="M8.62 17.95c-0.58,0.31 -1.2,0.42 -1.84,0.33 -0.63,-0.08 -1.23,-0.36 -1.7,-0.83l-0.01 -0.01 -0.01 0c-0.75,-0.09 -1.4,-0.45 -1.87,-0.96 -0.46,-0.52 -0.74,-1.18 -0.74,-1.91l0 -0.07 -0.02 -0.01c-0.02,-0.01 -0.04,-0.02 -0.05,-0.03 -0.1,-0.05 -0.17,-0.1 -0.27,-0.17 -0.09,-0.07 -0.18,-0.14 -0.27,-0.21 -0.07,-0.06 -0.13,-0.12 -0.19,-0.18 -0.11,-0.11 -0.21,-0.23 -0.3,-0.35 -0.14,-0.19 -0.26,-0.39 -0.36,-0.6 0,-0.02 -0.01,-0.03 -0.02,-0.03 -0.07,-0.18 -0.13,-0.36 -0.17,-0.55 -0.06,-0.24 -0.09,-0.49 -0.09,-0.73 0,-0.2 0.02,-0.38 0.06,-0.57 0.04,-0.22 0.1,-0.44 0.18,-0.64l0.02 -0.04c0.16,-0.37 0.39,-0.72 0.68,-1.02l0.01 -0.01 0 -0.03c-0.1,-0.4 -0.1,-0.81 -0.02,-1.21 0.03,-0.1 0.06,-0.19 0.09,-0.28 0.03,-0.09 0.05,-0.18 0.09,-0.26 0.15,-0.35 0.37,-0.67 0.66,-0.94l0.01 -0.01 0 -0.03c-0.11,-0.67 0.04,-1.32 0.37,-1.87 0.34,-0.56 0.86,-1 1.52,-1.25l0.02 0 0 -0.02c0.45,-1.12 1.56,-1.86 2.81,-1.86 0.45,0.01 0.88,0.1 1.27,0.29l0 15.62c0.01,0.17 0.05,0.31 0.14,0.44z"/> + <path class="aidmi-icon-fil0 aidmi-icon-str0" d="M15.59 16.48c-0.47,0.51 -1.12,0.87 -1.87,0.96l-0.01 0 -0.01 0.01c-0.47,0.47 -1.07,0.75 -1.7,0.83 -0.64,0.09 -1.26,-0.02 -1.84,-0.32 0.09,-0.14 0.14,-0.28 0.14,-0.45l0 -15.62c0.4,-0.19 0.82,-0.28 1.27,-0.29 1.25,0 2.37,0.74 2.81,1.86l0.01 0.02 0.02 0c0.66,0.25 1.18,0.69 1.51,1.25 0.34,0.55 0.48,1.2 0.38,1.87l0 0.03 0.01 0.01c0.28,0.27 0.51,0.59 0.66,0.94 0.04,0.08 0.06,0.17 0.09,0.27 0.03,0.09 0.06,0.18 0.08,0.27 0.09,0.4 0.09,0.81 -0.01,1.21l-0.01 0.03 0.02 0.01c0.29,0.3 0.52,0.65 0.68,1.03l0.01 0.03c0.06,0.14 0.11,0.28 0.14,0.43 -0.68,-0.48 -1.54,-0.85 -2.54,-0.85 -2.77,0 -4.44,2.84 -4.44,2.84l-0.01 0.02 0.01 0.03c0,0 1.67,2.83 4.44,2.83 0.23,0 0.46,-0.02 0.68,-0.06 -0.13,0.3 -0.31,0.57 -0.52,0.81z"/> + <path class="aidmi-icon-fil1" d="M20.04 12.85c0,0 -1.73,-2.91 -4.61,-2.91 -2.89,0 -4.62,2.91 -4.62,2.91 0,0 1.73,2.91 4.62,2.91 2.88,0 4.61,-2.91 4.61,-2.91zm-8.55 0c0.28,-0.39 0.6,-0.75 0.96,-1.08 0.74,-0.68 1.75,-1.3 2.98,-1.3 1.22,0 2.24,0.62 2.98,1.3 0.36,0.33 0.68,0.69 0.96,1.08 -0.04,0.05 -0.07,0.1 -0.12,0.15 -0.19,0.26 -0.47,0.59 -0.84,0.93 -0.74,0.68 -1.76,1.3 -2.98,1.3 -1.23,0 -2.24,-0.62 -2.98,-1.3 -0.36,-0.33 -0.68,-0.69 -0.96,-1.08l0 0z"/> + <path class="aidmi-icon-fil1" d="M15.63 11.82c-0.88,0 -1.43,0.84 -0.99,1.51 0.21,0.31 0.58,0.5 0.99,0.5 0.89,0 1.44,-0.84 1,-1.51 -0.21,-0.31 -0.59,-0.5 -1,-0.5zm-1.6 1c0,-1.08 1.34,-1.76 2.41,-1.22 0.49,0.25 0.8,0.72 0.8,1.22 0,1.09 -1.34,1.77 -2.41,1.23 -0.5,-0.26 -0.8,-0.72 -0.8,-1.23z"/> + <line class="aidmi-icon-fil2 aidmi-icon-str1" x1="12.92" y1="8.92" x2="17.64" y2= "16.79" /> + </g> +</svg> diff --git a/js/build/aidmiPlugin.js b/js/build/aidmiPlugin.js new file mode 100644 index 0000000000000000000000000000000000000000..25341e962729d28be0ab8156c29f76db44e3d40a --- /dev/null +++ b/js/build/aidmiPlugin.js @@ -0,0 +1 @@ +!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.CKEditor5=t():(e.CKEditor5=e.CKEditor5||{},e.CKEditor5.aidmiPlugin=t())}(self,(()=>(()=>{var e={"ckeditor5/src/core.js":(e,t,i)=>{e.exports=i("dll-reference CKEditor5.dll")("./src/core.js")},"ckeditor5/src/ui.js":(e,t,i)=>{e.exports=i("dll-reference CKEditor5.dll")("./src/ui.js")},"dll-reference CKEditor5.dll":e=>{"use strict";e.exports=CKEditor5.dll}},t={};function i(a){var l=t[a];if(void 0!==l)return l.exports;var n=t[a]={exports:{}};return e[a](n,n.exports,i),n.exports}i.d=(e,t)=>{for(var a in t)i.o(t,a)&&!i.o(e,a)&&Object.defineProperty(e,a,{enumerable:!0,get:t[a]})},i.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t);var a={};return(()=>{"use strict";i.d(a,{default:()=>r});var e=i("ckeditor5/src/core.js"),t=i("ckeditor5/src/ui.js");class l extends e.Plugin{init(){const e=this.editor;e.ui.componentFactory.add("aidmi",(i=>{const a=new t.ButtonView(i);return a.set({label:e.t("AI, describe my image!"),icon:'<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" width="20px" height="20px" version="1.1" style="shape-rendering:geometricPrecision; text-rendering:geometricPrecision; image-rendering:optimizeQuality; fill-rule:evenodd; clip-rule:evenodd"\r\nviewBox="0 0 20 20"\r\n xmlns:xlink="http://www.w3.org/1999/xlink">\r\n <g id="aidmi-icon-layer">\r\n <metadata id="aidmi-icon"/>\r\n <path class="aidmi-icon-fil0 aidmi-icon-str0" d="M8.62 17.95c-0.58,0.31 -1.2,0.42 -1.84,0.33 -0.63,-0.08 -1.23,-0.36 -1.7,-0.83l-0.01 -0.01 -0.01 0c-0.75,-0.09 -1.4,-0.45 -1.87,-0.96 -0.46,-0.52 -0.74,-1.18 -0.74,-1.91l0 -0.07 -0.02 -0.01c-0.02,-0.01 -0.04,-0.02 -0.05,-0.03 -0.1,-0.05 -0.17,-0.1 -0.27,-0.17 -0.09,-0.07 -0.18,-0.14 -0.27,-0.21 -0.07,-0.06 -0.13,-0.12 -0.19,-0.18 -0.11,-0.11 -0.21,-0.23 -0.3,-0.35 -0.14,-0.19 -0.26,-0.39 -0.36,-0.6 0,-0.02 -0.01,-0.03 -0.02,-0.03 -0.07,-0.18 -0.13,-0.36 -0.17,-0.55 -0.06,-0.24 -0.09,-0.49 -0.09,-0.73 0,-0.2 0.02,-0.38 0.06,-0.57 0.04,-0.22 0.1,-0.44 0.18,-0.64l0.02 -0.04c0.16,-0.37 0.39,-0.72 0.68,-1.02l0.01 -0.01 0 -0.03c-0.1,-0.4 -0.1,-0.81 -0.02,-1.21 0.03,-0.1 0.06,-0.19 0.09,-0.28 0.03,-0.09 0.05,-0.18 0.09,-0.26 0.15,-0.35 0.37,-0.67 0.66,-0.94l0.01 -0.01 0 -0.03c-0.11,-0.67 0.04,-1.32 0.37,-1.87 0.34,-0.56 0.86,-1 1.52,-1.25l0.02 0 0 -0.02c0.45,-1.12 1.56,-1.86 2.81,-1.86 0.45,0.01 0.88,0.1 1.27,0.29l0 15.62c0.01,0.17 0.05,0.31 0.14,0.44z"/>\r\n <path class="aidmi-icon-fil0 aidmi-icon-str0" d="M15.59 16.48c-0.47,0.51 -1.12,0.87 -1.87,0.96l-0.01 0 -0.01 0.01c-0.47,0.47 -1.07,0.75 -1.7,0.83 -0.64,0.09 -1.26,-0.02 -1.84,-0.32 0.09,-0.14 0.14,-0.28 0.14,-0.45l0 -15.62c0.4,-0.19 0.82,-0.28 1.27,-0.29 1.25,0 2.37,0.74 2.81,1.86l0.01 0.02 0.02 0c0.66,0.25 1.18,0.69 1.51,1.25 0.34,0.55 0.48,1.2 0.38,1.87l0 0.03 0.01 0.01c0.28,0.27 0.51,0.59 0.66,0.94 0.04,0.08 0.06,0.17 0.09,0.27 0.03,0.09 0.06,0.18 0.08,0.27 0.09,0.4 0.09,0.81 -0.01,1.21l-0.01 0.03 0.02 0.01c0.29,0.3 0.52,0.65 0.68,1.03l0.01 0.03c0.06,0.14 0.11,0.28 0.14,0.43 -0.68,-0.48 -1.54,-0.85 -2.54,-0.85 -2.77,0 -4.44,2.84 -4.44,2.84l-0.01 0.02 0.01 0.03c0,0 1.67,2.83 4.44,2.83 0.23,0 0.46,-0.02 0.68,-0.06 -0.13,0.3 -0.31,0.57 -0.52,0.81z"/>\r\n <path class="aidmi-icon-fil1" d="M20.04 12.85c0,0 -1.73,-2.91 -4.61,-2.91 -2.89,0 -4.62,2.91 -4.62,2.91 0,0 1.73,2.91 4.62,2.91 2.88,0 4.61,-2.91 4.61,-2.91zm-8.55 0c0.28,-0.39 0.6,-0.75 0.96,-1.08 0.74,-0.68 1.75,-1.3 2.98,-1.3 1.22,0 2.24,0.62 2.98,1.3 0.36,0.33 0.68,0.69 0.96,1.08 -0.04,0.05 -0.07,0.1 -0.12,0.15 -0.19,0.26 -0.47,0.59 -0.84,0.93 -0.74,0.68 -1.76,1.3 -2.98,1.3 -1.23,0 -2.24,-0.62 -2.98,-1.3 -0.36,-0.33 -0.68,-0.69 -0.96,-1.08l0 0z"/>\r\n <path class="aidmi-icon-fil1" d="M15.63 11.82c-0.88,0 -1.43,0.84 -0.99,1.51 0.21,0.31 0.58,0.5 0.99,0.5 0.89,0 1.44,-0.84 1,-1.51 -0.21,-0.31 -0.59,-0.5 -1,-0.5zm-1.6 1c0,-1.08 1.34,-1.76 2.41,-1.22 0.49,0.25 0.8,0.72 0.8,1.22 0,1.09 -1.34,1.77 -2.41,1.23 -0.5,-0.26 -0.8,-0.72 -0.8,-1.23z"/>\r\n <line class="aidmi-icon-fil2 aidmi-icon-str1" x1="12.92" y1="8.92" x2="17.64" y2= "16.79" />\r\n </g>\r\n</svg>\r\n',tooltip:!0}),this.listenTo(a,"execute",(async()=>{try{let t=null,i=null,a=this._aidmiJSONImages(e.getData());a.length>0&&(t=await this._aidmiContentAjax(e.getData(),a)),t&&(i=JSON.parse(t),this._openDialog(e,i))}catch(e){console.log("Error:",e)}})),a}))}_aidmiJSONImages(e){const t=document.createElement("div");t.innerHTML=e;let i=t.querySelectorAll("img"),a=[];return i.forEach((e=>{let t={"data-entity-uuid":e.getAttribute("data-entity-uuid"),src:e.getAttribute("src")};a.push(t)})),a}_aidmiContentAjax(e,t){const i=jQuery;return new Promise(((a,l)=>{i.ajax({url:"/admin/aidmi/describe-content-ajax",method:"POST",data:{content:e,imagesJSON:JSON.stringify(t)},success:function(e){a(e)},error:function(e,t,i){l(i)}})}))}_openDialog(e,t,i){const a=document.createElement("div");a.setAttribute("role","dialog"),a.setAttribute("aria-labelledby","aidmi-dialog-title"),a.setAttribute("aria-describedby","aidmi-dialog-description"),a.setAttribute("aria-modal","true");a.innerHTML=`<p id="aidmi-dialog-subtitle" class="aidmi-dialog-subtitle">${Drupal.t("Please evaluate and edit the description as needed.")}</p>`;t.reduce(((e,t)=>(t.forEach((t=>{t.images&&Array.isArray(t.images)&&(e+=t.images.length)})),e)),0);t.forEach((e=>{e.forEach((e=>{e.images&&Array.isArray(e.images)&&e.images.forEach((e=>{let t="";t+=`<div class="image-option-group" id="option-group-${e["data-entity-uuid"]}">`,t+=`\n \x3c!-- Image Display --\x3e\n <img class="aidmi-dialog-image" data-entity-uuid="${e["data-entity-uuid"]}" src="${e.src}" alt="${Drupal.t(e.before_alt)}" />`;let i=!1,l="Current Alt Tag - Read only",n=e.before_alt;(!n||n.length<6)&&(l+=" (Option disabled since alt is either blank or too short)",i=!0),t+=`\n \x3c!-- Current Alt Tag Section --\x3e\n <div>\n <label for="aidmi-dialog-textarea-oldalt-${e["data-entity-uuid"]}" class="aidmi-dialog-label">\n <input type="radio" name="alt-selection-${e["data-entity-uuid"]}" id="alt-old-${e["data-entity-uuid"]}" value="current" aria-labelledby="label-old-${e["data-entity-uuid"]}" ${i?"disabled":""} required/>\n <span id="label-old-${e["data-entity-uuid"]}">${Drupal.t(l)}</span>\n </label>\n <textarea id="aidmi-dialog-textarea-oldalt-${e["data-entity-uuid"]}" class="aidmi-dialog-textarea" rows="3" aria-labelledby="label-old-${e["data-entity-uuid"]}" readonly="readonly">${Drupal.t(e.before_alt)}</textarea>\n </div>`,t+=`\n \x3c!-- Suggested Alt Tag Section --\x3e\n <div>\n <label for="aidmi-dialog-textarea-newalt-${e["data-entity-uuid"]}" class="aidmi-dialog-label">\n <input type="radio" name="alt-selection-${e["data-entity-uuid"]}" id="alt-new-${e["data-entity-uuid"]}" value="suggested" aria-labelledby="label-new-${e["data-entity-uuid"]}" required>\n <span id="label-new-${e["data-entity-uuid"]}">${Drupal.t("Suggested Alt Tag - Review and modify as required")}</span>\n </label>\n <textarea id="aidmi-dialog-textarea-newalt-${e["data-entity-uuid"]}" class="aidmi-dialog-textarea" rows="3" aria-labelledby="label-new-${e["data-entity-uuid"]}">${Drupal.t(e.recommendation)}</textarea>\n </div>`,t+=`\n \x3c!-- Set as Decorative Image --\x3e\n <div>\n <label for="aidmi-dialog-textarea-dilt-${e["data-entity-uuid"]}" class="aidmi-dialog-label">\n <input type="radio" name="alt-selection-${e["data-entity-uuid"]}" id="alt-decorative-${e["data-entity-uuid"]}" value="decorative" aria-labelledby="label-decorative-${e["data-entity-uuid"]}" required>\n <span id="label-decorative-${e["data-entity-uuid"]}">${Drupal.t("Set as Decorative Image")}</span>\n </label>\n </div>`,t+="</div><hr/>",a.innerHTML+=t}))}))}));const l={dialogClass:"aidmi-dialog",title:"AIDmi Description",resizable:!0,width:"700px",buttons:[{text:Drupal.t("Insert Text"),class:"aidmi-insert-button",click:()=>{const i=this._collectSelections(t);i?(this._modifyImagesInContent(e,i),n.close()):alert(Drupal.t("Please select an option for each image before inserting."))}},{text:Drupal.t("Cancel"),click:function(){n.close()}}]},n=Drupal.dialog(a,l);n.showModal(),document.querySelectorAll('input[type="radio"]').forEach((e=>{e.addEventListener("change",(e=>{const t=e.target.name.replace("alt-selection-","option-group-");document.getElementById(t).classList.remove("aidmi-highlight-missing")}))})),document.querySelector('input[type="radio"]').focus()}_collectSelections(e){let t=!0;const i=[];return e.forEach((e=>{e.forEach((e=>{e.images&&Array.isArray(e.images)&&e.images.forEach((e=>{const a=e["data-entity-uuid"],l=document.querySelector(`input[name="alt-selection-${a}"]:checked`);if(l){const e=l.value;let t="";"current"===e?t=document.getElementById(`aidmi-dialog-textarea-oldalt-${a}`).value:"suggested"===e&&(t=document.getElementById(`aidmi-dialog-textarea-newalt-${a}`).value),i.push({uuid:a,altText:"decorative"===e?"":t,isDecorative:"decorative"===e})}else t=!1,document.getElementById(`option-group-${a}`).classList.add("aidmi-highlight-missing")}))}))})),t?i:null}_modifyImagesInContent(e,t){let i=e.getData();const a=document.createElement("div");a.innerHTML=i,t.forEach((e=>{const t=a.querySelector(`img[data-entity-uuid="${e.uuid}"]`);t?e.isDecorative?(t.setAttribute("alt",""),t.setAttribute("role","presentation")):(t.setAttribute("alt",e.altText),t.removeAttribute("role")):console.warn(`Image with UUID ${e.uuid} not found in content.`)})),e.setData(a.innerHTML),e.editing.view.document.fire("change:data")}}class n extends e.Plugin{static get requires(){return[l]}static get pluginName(){return"AIDmi"}}const r={AIDmi:n}})(),a=a.default})())); \ No newline at end of file diff --git a/js/ckeditor5_plugins/aidmiPlugin/src/aidmi.js b/js/ckeditor5_plugins/aidmiPlugin/src/aidmi.js new file mode 100644 index 0000000000000000000000000000000000000000..c2476916a7c21da76798f1b019c4e318fbe7a683 --- /dev/null +++ b/js/ckeditor5_plugins/aidmiPlugin/src/aidmi.js @@ -0,0 +1,19 @@ + +import { Plugin } from 'ckeditor5/src/core'; + +// import AIDmiEditing from './aidmiediting'; +import AIDmiUI from './aidmiui'; + +export default class AIDmi extends Plugin { + static get requires() { + // AIDmiEditing, + return [ AIDmiUI ]; + } + /** + * @inheritdoc + */ + static get pluginName() { + return 'AIDmi'; + } +} + diff --git a/js/ckeditor5_plugins/aidmiPlugin/src/aidmiui.js b/js/ckeditor5_plugins/aidmiPlugin/src/aidmiui.js new file mode 100644 index 0000000000000000000000000000000000000000..a32fa34e9b910a4916b487f76f58b01e27b0be09 --- /dev/null +++ b/js/ckeditor5_plugins/aidmiPlugin/src/aidmiui.js @@ -0,0 +1,311 @@ +import { Plugin } from 'ckeditor5/src/core'; +import { + ButtonView, + FocusCycler, + LabeledFieldView, + SwitchButtonView, + View, + ViewCollection, + createLabeledInputText, + injectCssTransitionDisabler, + submitHandler, +} from 'ckeditor5/src/ui'; + +import iconAidmi from '../../../../icons/aidmi.svg'; + +export default class AIDmiUI extends Plugin { + init() { + const editor = this.editor; + + // This will register the AIDmi toolbar button. + editor.ui.componentFactory.add('aidmi', (locale) => { + const button = new ButtonView(locale); + + // Create the toolbar button. + button.set({ + label: editor.t('AI, describe my image!'), + icon: iconAidmi, + tooltip: true, + }); + + this.listenTo(button, 'execute', async () => { + try { + let output = null; + let parsedOutput = null; + let imagesJSON = this._aidmiJSONImages(editor.getData()); + if (imagesJSON.length > 0) { + output = await this._aidmiContentAjax(editor.getData(), imagesJSON); + } + if (output) { + parsedOutput = JSON.parse(output); + this._openDialog(editor, parsedOutput); + } + } catch (error) { + console.log("Error:", error); + } + }); + + return button; + }); + + } + + _aidmiJSONImages(content) { + // Create a temporary DOM element to parse the HTML string + const tempDiv = document.createElement('div'); + tempDiv.innerHTML = content; // Convert the HTML string to DOM elements + // Find all images with the class 'aidmi-dialog-image' + let images = tempDiv.querySelectorAll('img'); + + // Initialize an empty array to store the JSON data + let imageJsonArray = []; + + // Loop through each image and extract 'data-entity-uuid' and 'src' + images.forEach((image) => { + let imageObject = { + "data-entity-uuid": image.getAttribute('data-entity-uuid'), + "src": image.getAttribute('src') + }; + + // Push the image object to the array + imageJsonArray.push(imageObject); + }); + + // Convert the array to a JSON string for output or further use + return imageJsonArray; + } + + _aidmiContentAjax(content, imagesJSON) { + const $ = jQuery; + return new Promise((resolve, reject) => { + $.ajax({ + url: '/admin/aidmi/describe-content-ajax', + method: 'POST', + data: { + content: content, + imagesJSON: JSON.stringify(imagesJSON) + }, + success: function(response) { + resolve(response); + }, + error: function(xhr, status, error) { + reject(error); + } + }); + }); + } + + _openDialog(editor, data, callback) { + let aidmiEditedText; + // Create a temporary container to hold the content. + const tempElement = document.createElement('div'); + tempElement.setAttribute('role', 'dialog'); + tempElement.setAttribute('aria-labelledby', 'aidmi-dialog-title'); + tempElement.setAttribute('aria-describedby', 'aidmi-dialog-description'); + tempElement.setAttribute('aria-modal', 'true'); // Ensures screen readers treat the dialog as a modal. + + // Set title text. + let tempETitle = 'AIDmi Description'; + let tempESubTitle = 'Please evaluate and edit the description as needed.'; + + // Create dialog content with a textarea for editing. + tempElement.innerHTML = `<p id="aidmi-dialog-subtitle" class="aidmi-dialog-subtitle">${Drupal.t(tempESubTitle)}</p>`; + + // Variable to store the number of images + const imageCount = data.reduce((count, outerArray) => { + outerArray.forEach((item) => { + if (item.images && Array.isArray(item.images)) { + count += item.images.length; + } + }); + return count; + }, 0); + + // Loop through the images + data.forEach((outerArray) => { + outerArray.forEach((item) => { + // If item has an images array, loop through it + if (item.images && Array.isArray(item.images)) { + item.images.forEach((image) => { + let formImageInnerHTML = ''; + // Start of image div alt option area. + formImageInnerHTML += `<div class="image-option-group" id="option-group-${image['data-entity-uuid']}">`; + // Add Image Display. + formImageInnerHTML += ` + <!-- Image Display --> + <img class="aidmi-dialog-image" data-entity-uuid="${image['data-entity-uuid']}" src="${image.src}" alt="${Drupal.t(image.before_alt)}" />`; + // Add Current Alt Tag Section. + let imageOldSelectDisabled = false; + let imageLabel = 'Current Alt Tag - Read only'; + let imageBeforeAlt = image.before_alt; + if (!imageBeforeAlt || imageBeforeAlt.length < 6) { + // Old alt shouldn't be an allowed option. + imageLabel += ' (Option disabled since alt is either blank or too short)'; + imageOldSelectDisabled = true; + } + formImageInnerHTML += ` + <!-- Current Alt Tag Section --> + <div> + <label for="aidmi-dialog-textarea-oldalt-${image['data-entity-uuid']}" class="aidmi-dialog-label"> + <input type="radio" name="alt-selection-${image['data-entity-uuid']}" id="alt-old-${image['data-entity-uuid']}" value="current" aria-labelledby="label-old-${image['data-entity-uuid']}" ${imageOldSelectDisabled ? 'disabled' : ''} required/> + <span id="label-old-${image['data-entity-uuid']}">${Drupal.t(imageLabel)}</span> + </label> + <textarea id="aidmi-dialog-textarea-oldalt-${image['data-entity-uuid']}" class="aidmi-dialog-textarea" rows="3" aria-labelledby="label-old-${image['data-entity-uuid']}" readonly="readonly">${Drupal.t(image.before_alt)}</textarea> + </div>`; + + // Add Suggested Alt Tag Section. + formImageInnerHTML += ` + <!-- Suggested Alt Tag Section --> + <div> + <label for="aidmi-dialog-textarea-newalt-${image['data-entity-uuid']}" class="aidmi-dialog-label"> + <input type="radio" name="alt-selection-${image['data-entity-uuid']}" id="alt-new-${image['data-entity-uuid']}" value="suggested" aria-labelledby="label-new-${image['data-entity-uuid']}" required> + <span id="label-new-${image['data-entity-uuid']}">${Drupal.t('Suggested Alt Tag - Review and modify as required')}</span> + </label> + <textarea id="aidmi-dialog-textarea-newalt-${image['data-entity-uuid']}" class="aidmi-dialog-textarea" rows="3" aria-labelledby="label-new-${image['data-entity-uuid']}">${Drupal.t(image.recommendation)}</textarea> + </div>`; + // Add Set as Decorative Image. + formImageInnerHTML += ` + <!-- Set as Decorative Image --> + <div> + <label for="aidmi-dialog-textarea-dilt-${image['data-entity-uuid']}" class="aidmi-dialog-label"> + <input type="radio" name="alt-selection-${image['data-entity-uuid']}" id="alt-decorative-${image['data-entity-uuid']}" value="decorative" aria-labelledby="label-decorative-${image['data-entity-uuid']}" required> + <span id="label-decorative-${image['data-entity-uuid']}">${Drupal.t('Set as Decorative Image')}</span> + </label> + </div>`; + // End of image div alt option area. + formImageInnerHTML += `</div><hr/>`; + + // Insert it. + tempElement.innerHTML += formImageInnerHTML; + }); + } + }); + }); + + // Use the Drupal off-canvas dialog to show the content. + const options = { + dialogClass: 'aidmi-dialog', + title: tempETitle, + resizable: true, + width: '700px', + buttons: [ + { + text: Drupal.t('Insert Text'), + class: 'aidmi-insert-button', // Add a class to the insert button for reference + click: () => { + // Validate all selections before inserting text + const selections = this._collectSelections(data); + if (selections) { + // Modify the images in the editor content based on the selections + this._modifyImagesInContent(editor, selections); + dialogInstance.close(); + } else { + // Show a message if validation fails + alert(Drupal.t('Please select an option for each image before inserting.')); + } + } + }, + { + text: Drupal.t('Cancel'), + click: function () { + // Close the dialog. + dialogInstance.close(); + } + } + ] + }; + + // Open the dialog using Drupal's dialog API. + const dialogInstance = Drupal.dialog(tempElement, options); + dialogInstance.showModal(); + + // Add event listeners to all radio buttons to validate the selection and remove highlights + document.querySelectorAll('input[type="radio"]').forEach(radio => { + radio.addEventListener('change', (event) => { + const groupId = event.target.name.replace('alt-selection-', 'option-group-'); + // Remove the highlight class when a selection is made + document.getElementById(groupId).classList.remove('aidmi-highlight-missing'); + }); + }); + + // Set focus on the first input for screen readers + document.querySelector('input[type="radio"]').focus(); + } + + _collectSelections(data) { + let allSelected = true; + const selections = []; + + // Iterate through each item in the data array to process images + data.forEach((outerArray) => { + outerArray.forEach((item) => { + if (item.images && Array.isArray(item.images)) { + item.images.forEach((image) => { + const uuid = image['data-entity-uuid']; + const selectedRadio = document.querySelector(`input[name="alt-selection-${uuid}"]:checked`); + + // If no radio button is selected, set `allSelected` to false + if (!selectedRadio) { + allSelected = false; + // Highlight the missing selection group + document.getElementById(`option-group-${uuid}`).classList.add('aidmi-highlight-missing'); + } else { + // Get the selected value and corresponding alt text or set as decorative + const altValue = selectedRadio.value; + let altText = ''; + if (altValue === 'current') { + altText = document.getElementById(`aidmi-dialog-textarea-oldalt-${uuid}`).value; + } else if (altValue === 'suggested') { + altText = document.getElementById(`aidmi-dialog-textarea-newalt-${uuid}`).value; + } + selections.push({ + uuid: uuid, + altText: altValue === 'decorative' ? '' : altText, // Leave alt text empty for decorative images + isDecorative: altValue === 'decorative' + }); + } + }); + } + }); + }); + + return allSelected ? selections : null; + } + + // Function to modify the images in the CKEditor content based on the selected options + _modifyImagesInContent(editor, selections) { + // Get the current content of the editor + let content = editor.getData(); + + // Create a temporary DOM element to manipulate the HTML content + const tempDiv = document.createElement('div'); + tempDiv.innerHTML = content; // Convert the HTML string to DOM elements + + // Loop through the selections and modify the corresponding images in the content + selections.forEach(selection => { + // Find the image element by its data-entity-uuid attribute + const imageElement = tempDiv.querySelector(`img[data-entity-uuid="${selection.uuid}"]`); + + if (imageElement) { + // Update the alt attribute or set as decorative based on the selection + if (selection.isDecorative) { + imageElement.setAttribute('alt', ''); // Set alt attribute to empty for decorative images + imageElement.setAttribute('role', 'presentation'); // Add role="presentation" to decorative images + } else { + imageElement.setAttribute('alt', selection.altText); // Update the alt attribute + imageElement.removeAttribute('role'); // Remove role attribute if it's not a decorative image + } + + } else { + console.warn(`Image with UUID ${selection.uuid} not found in content.`); + } + }); + + // Set the modified content back to the editor + editor.setData(tempDiv.innerHTML); + + // Optional: Trigger the change event to update the editor state + editor.editing.view.document.fire('change:data'); + } + +} diff --git a/js/ckeditor5_plugins/aidmiPlugin/src/index.js b/js/ckeditor5_plugins/aidmiPlugin/src/index.js new file mode 100644 index 0000000000000000000000000000000000000000..697161c91458e367e4080c0992b6f1f22be42e59 --- /dev/null +++ b/js/ckeditor5_plugins/aidmiPlugin/src/index.js @@ -0,0 +1,5 @@ +import AIDmi from './aidmi'; + +export default { + AIDmi: AIDmi, +}; diff --git a/js/ckeditor5_plugins/aidmiPlugin/src/old.aidmicommand.js b/js/ckeditor5_plugins/aidmiPlugin/src/old.aidmicommand.js new file mode 100644 index 0000000000000000000000000000000000000000..9aa24d6f74d9f1e234ae57fd7fca5af469bc682a --- /dev/null +++ b/js/ckeditor5_plugins/aidmiPlugin/src/old.aidmicommand.js @@ -0,0 +1,24 @@ +import { Command } from 'ckeditor5/src/core'; + + +export default class AIDmiCommand extends Command { + execute() { + const editor = this.editor; + const selection = editor.model.document.selection; + + editor.model.change( writer => { + console.write('Hit it!'); + console.write(editor); + console.write(selection); + } ); + } + + refresh() { + const model = this.editor.model; + const selection = model.document.selection; + + //const isAllowed = model.schema.checkChild( selection.focus.parent, 'aidmiGemini' ); + + this.isEnabled = true; + } +} \ No newline at end of file diff --git a/js/ckeditor5_plugins/aidmiPlugin/src/old.aidmiediting.js b/js/ckeditor5_plugins/aidmiPlugin/src/old.aidmiediting.js new file mode 100644 index 0000000000000000000000000000000000000000..4e0a6b0fb4fffd40fa4441596858a61d4ef5bfad --- /dev/null +++ b/js/ckeditor5_plugins/aidmiPlugin/src/old.aidmiediting.js @@ -0,0 +1,34 @@ +import { Plugin } from 'ckeditor5/src/core'; +import { Widget } from 'ckeditor5/src/widget'; + +import AIDmiCommand from './aidmicommand'; + +export default class AIDmiEditing extends Plugin { + // + constructor( editor ) { + // The default constructor calls the parent constructor. + super( editor ); + // Last fetched value + this.externalDataValue = ''; + } + + static get requires() { + return [ Widget ]; + } + + // This method will help us to clear the interval + destroy() { + clearInterval( this.intervalId ); + } + + init() { + this.editor.commands.add( 'external', new AIDmiCommand( this.editor ) ); + } + + /** + * @inheritdoc + */ + static get pluginName() { + return 'AIDmiEditing'; + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000000000000000000000000000000000000..1fb4cb807d25103d091c02408782611279abab44 --- /dev/null +++ b/package.json @@ -0,0 +1,22 @@ +{ + "name": "drupal-ckeditor5", + "version": "1.0.0", + "description": "Drupal CKEditor 5 integration", + "author": "", + "license": "GPL-2.0-or-later", + "scripts": { + "watch": "webpack --mode development --watch", + "build": "webpack" + }, + "devDependencies": { + "@ckeditor/ckeditor5-dev-utils": "^30.0.0", + "ckeditor5": "~34.1.0", + "raw-loader": "^4.0.2", + "terser-webpack-plugin": "^5.2.0", + "webpack": "^5.51.1", + "webpack-cli": "^4.4.0" + }, + "dependencies": { + "jquery": "^3.7.1" + } +} diff --git a/src/Controller/AidmiController.php b/src/Controller/AidmiController.php index 32e9eeb75de7638f7cd00344ef3cc534374a8008..8336de1932bd5ec768a981e1b50de1511e8796bb 100644 --- a/src/Controller/AidmiController.php +++ b/src/Controller/AidmiController.php @@ -43,6 +43,19 @@ class AidmiController { } return null; } + + public function analyzeContentAjax(): Response { + // Get the raw POST data from the request. + $content = \Drupal::request()->request->get('content'); + $imagesJSON = \Drupal::request()->request->get('imagesJSON'); + $description = null; + + if (!empty($content)) { + $description = $this->geminiAiService->analyzeContent($content, $imagesJSON); + } + + return new Response($description); + } public function getFileIdByUuid(string $uuid) { // Query for the file entity using the UUID. diff --git a/src/Plugin/CKEditor5Plugin/aidmi.php b/src/Plugin/CKEditor5Plugin/aidmi.php new file mode 100644 index 0000000000000000000000000000000000000000..96677f1bc1892b347c08a077f359f182f8c5ddd0 --- /dev/null +++ b/src/Plugin/CKEditor5Plugin/aidmi.php @@ -0,0 +1,33 @@ +<?php + +declare(strict_types = 1); + +namespace Drupal\aidmi\Plugin\CKEditor5Plugin; + +use Drupal\ckeditor5\Plugin\CKEditor5PluginDefault; +use Drupal\Core\Url; +use Drupal\editor\EditorInterface; + +/** + * Plugin class to add dialog url for embedded content. + */ +class aidmi extends CKEditor5PluginDefault { + + /** + * {@inheritdoc} + */ + public function getDynamicPluginConfig(array $static_plugin_config, EditorInterface $editor): array { + $aidmi_dialog_url = Url::fromRoute('ckeditor5_aidmi.dialog') + ->toString(TRUE) + ->getGeneratedUrl(); + $static_plugin_config['aidmi']['dialogURL'] = $aidmi_dialog_url; + $aidmi_preview_url = Url::fromRoute('ckeditor5_aidmi.preview', [ + 'editor' => $editor->id(), + ]) + ->toString(TRUE) + ->getGeneratedUrl(); + // $static_plugin_config['aidmi']['previewURL'] = $aidmi_preview_url; + return $static_plugin_config; + } + +} diff --git a/src/Service/GeminiAiService.php b/src/Service/GeminiAiService.php index f6fc68d0f090b1d246252dd5de23d9c1a6ff0f1a..d7ee8c9df44689869bbfcc95072040f30ab6b4af 100644 --- a/src/Service/GeminiAiService.php +++ b/src/Service/GeminiAiService.php @@ -3,6 +3,8 @@ namespace Drupal\aidmi\Service; use GeminiAPI\Client; +use GeminiAPI\GenerationConfig; +use GeminiAPI\Resources\Parts\PartInterface; use GeminiAPI\Enums\MimeType; use GeminiAPI\Resources\Parts\ImagePart; use GeminiAPI\Resources\Parts\TextPart; @@ -33,9 +35,16 @@ class GeminiAiService { */ protected $apiInstructions; + /** + * The AIDmi controller. + * + * @var \Drupal\aidmi\Controller\AidmiController + */ + protected $aidmiController; + + public function __construct(ConfigFactoryInterface $configFactory) { $this->configFactory = $configFactory; - // Retrieve the configuration for the module. $config = $configFactory->get('aidmi.settings'); // Get the input method. @@ -68,7 +77,7 @@ class GeminiAiService { $apiKey = '0'; } } - + $this->client = new Client($apiKey); $this->apiInstructions = $config->get('api_instructions'); } @@ -102,4 +111,59 @@ class GeminiAiService { } } + public function analyzeContent(string $content, string $imagesJSON): string { + try { + // Decode JSON string into PHP array + $jsonArray = json_decode($imagesJSON, true); + + $generateContent[] = new TextPart('Response can only be pure JSON, do not provide null value if something is empty, just keep it "" blank. Example: [{"images": [{"data-entity-uuid": "", "src": "", "before_alt": "", "recommendation": "", "decorative_image" = ""}]}, {"message": "error or other message if needed"}]. Review this content and describe the image. Actual images from the content are attached and referenced by data-entity-uuid. Try to provide some text in context for an accessibilty alt text. For graphs, make sure the description provides a detail for the reader to understand and include some relevant information perceived from it. For individuals, provide a short description. Try to provide short details based on context of where image is used in text. Provide a json output with each image by data-entity-uuid, image src, the before alt text if available, and your recommendation. If there is a problem, respond with only an error JSON. Provide a true or false for decorative image where just an alt tag alone is decorative, but alt="" is false.'); + $generateContent[] = new TextPart($content); + + // Loop through the array of files in content. + foreach ($jsonArray as $item) { + // Get file and contents. + $file = $this->getFileIdByUuid($item['data-entity-uuid']); + $imagePath = $file->getFileUri(); + $imageContent = file_get_contents($imagePath); + if (!$imageContent) { + throw new \Exception($this->t('Unable to open image file.')); + } + + // Attach images for Gemini AI review. + $generateContent[] = new TextPart("data-entity-uuid: ". $item['data-entity-uuid']); + $generateContent[] = new ImagePart( + MimeType::IMAGE_JPEG, + base64_encode($imageContent) + ); + } + + // Create the response. + $response = $this->client + ->withRequestHeaders([ + 'response_mime_type' => 'application/json' + ])->geminiProFlash1_5() + ->generateContent(...$generateContent); + + // Convert to full JSON and return the description from Gemini API. + $output = $response->text(); + $output = str_replace("```json","[", $output); + $output = str_replace("```","]", $output); + + return $output; + } catch (\Exception $e) { + return 'Error: ' . $e->getMessage(); + } + } + + public function getFileIdByUuid(string $uuid) { + // Query for the file entity using the UUID. + $file = \Drupal::entityTypeManager()->getStorage('file')->loadByProperties(['uuid' => $uuid]); + + if ($file) { + $file = reset($file); // Get the first result. + return $file; + } else { + throw new \Exception($this->t('File not found.')); + } + } } diff --git a/webpack.config.js b/webpack.config.js new file mode 100644 index 0000000000000000000000000000000000000000..a4c073ecd99170c329ef577322f162673e6fd166 --- /dev/null +++ b/webpack.config.js @@ -0,0 +1,66 @@ +const path = require('path'); +const fs = require('fs'); +const webpack = require('webpack'); +const { styles, builds } = require('@ckeditor/ckeditor5-dev-utils'); +const TerserPlugin = require('terser-webpack-plugin'); + +function getDirectories(srcpath) { + return fs + .readdirSync(srcpath) + .filter((item) => fs.statSync(path.join(srcpath, item)).isDirectory()); +} + +module.exports = []; +// Loop through every subdirectory in src, each a different plugin, and build +// each one in ./build. +getDirectories('./js/ckeditor5_plugins').forEach((dir) => { + const bc = { + mode: 'production', + optimization: { + minimize: true, + minimizer: [ + new TerserPlugin({ + terserOptions: { + format: { + comments: false, + }, + }, + test: /\.js(\?.*)?$/i, + extractComments: false, + }), + ], + moduleIds: 'named', + }, + entry: { + path: path.resolve( + __dirname, + 'js/ckeditor5_plugins', + dir, + 'src/index.js', + ), + }, + output: { + path: path.resolve(__dirname, './js/build'), + filename: `${dir}.js`, + library: ['CKEditor5', dir], + libraryTarget: 'umd', + libraryExport: 'default', + }, + plugins: [ + // It is possible to require the ckeditor5-dll.manifest.json used in + // core/node_modules rather than having to install CKEditor 5 here. + // However, that requires knowing the location of that file relative to + // where your module code is located. + new webpack.DllReferencePlugin({ + manifest: require('./node_modules/ckeditor5/build/ckeditor5-dll.manifest.json'), // eslint-disable-line global-require, import/no-unresolved + scope: 'ckeditor5/src', + name: 'CKEditor5.dll', + }), + ], + module: { + rules: [{ test: /\.svg$/, use: 'raw-loader' }], + }, + }; + + module.exports.push(bc); +});