Skip to content
Snippets Groups Projects
Commit b327368f authored by Mike Feranda's avatar Mike Feranda
Browse files

Issue #3476049 by mferanda: Add button to toolbar for AIDmi, evaluate all content images

parent 533f6aaf
Branches
Tags
No related merge requests found
Showing
with 684 additions and 18 deletions
package-lock.json
node_modules
......@@ -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
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">
......@@ -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
......@@ -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';
}
......@@ -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
......@@ -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
<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>
!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
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';
}
}
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');
}
}
import AIDmi from './aidmi';
export default {
AIDmi: AIDmi,
};
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
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';
}
}
{
"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"
}
}
......@@ -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.
......
<?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;
}
}
......@@ -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.'));
}
}
}
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);
});
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment