Friday, September 19, 2008

CRM 4.0 inline spell checker

I was looking integrate an inline spellchecker with Input and Textarea fields in crm. The idea was to enable spelling checks on any nvarchar field that users can activate by pressing shortcut combination keys e.g. Alt + Z. During my quest I stumbled on jianwang blog and saw the following post which uses the local machine (user) Microsoft word spell checker. I really liked the idea but hated the clumsy UI. So eventually I decided to try integrating my custom tool tip post with a simple retrieval of the Spelling Errors suggestion list. And, as always, what started as a simple programming task turned out to be a pain in the butt since I had to mimic the spell checker UI functionality.


I think the result came out pretty cool and since there are plenty of free spell checker out there why not add another one.
The usage is very simple! You create an instance of the InlineSpellChecker class decide on a shortcut combination key (default keyCode is 90 [z]) add the field or fields you what to enable spell checking on and you’re done.


The spell checker object is not called until the user presses Alt + z which mean the code does not affect load time. The first time might take a second or 2 but after that it works pretty quickly. I didn’t test it with large data so that might have an impact as well.


The UI have 4 buttons, the first 2 allow you to navigate between the list of spelling errors and the last 2 enables you to replace the error(s) with one of the selected suggestions. The field focus is never lost so you can open the spell checker and continue writing while it is open. The user can press Alt + z while writing to refresh the spelling errors suggestion list to accommodate the new written words.


When you select a suggestion it is painted in red. If you replace 1 occurrence then the suggestion is painter in orange. If all occurrences have been replaced the suggestion is painted in green. The current spelling error is also highlighted so the user can keep track. The image bellow displays a graphical representation of the above.


My only disclaimer is that you give me your feedback should you choose to use it in your customer’s application.






 
var Spellcheker;

function OnCrmPageLoad()
{
Spellcheker = new InlineSpellcheker();
Spellcheker.KeyCode = 90;
Spellcheker.AddField("gi_name");
}


function InlineSpellcheker()
{
/* Private Fields */
var Instance = this;
var mswordApp;
var activeDoc;

/* Public Fields */
Instance.KeyCode = 90; //Z
Instance.Fields = [];
Instance.SBox = new SuggestionBox();

/* Public Methods */
/* Add Fields with spell checking */
Instance.AddFields = function()
{
for( var i = 0 ; i < arguments.length; i++ )
{
var field = document.getElementById( arguments[ i ] );
if( isNullOrEmpty( field )) return;

Instance.Fields[ field.id ] = field;
field.title = "Press Alt + ";
field.title += String.fromCharCode(Instance.KeyCode);
field.title += " to use SpellChecker";

field.attachEvent( "onkeydown" , onSpellCheckingTest );
field.attachEvent( "onfocusout" , onLostFocusCleanUp );
}
}
/* Add a Field with spell checking*/
Instance.AddField = function( fieldId ){
Instance.AddFields( fieldId );
}
/* Kill word application */
Instance.Quite = function()
{
if( !isNullOrEmpty( mswordApp ) )
{
mswordApp.ActiveDocument.Close( 0 );
mswordApp.Quit( 0 );
}
activeDoc = null;
mswordApp = null;
}

/* Event Callbacks */
function onLostFocusCleanUp()
{
var field = Instance.Fields[ event.srcElement.id ];
if( isNullOrEmpty( field ) ) return;

field.SpellingErrors = null;
field.Box = null;
Instance.SBox.Clean();
}

function onSpellCheckingTest()
{

var scKeyStroke = event.altKey && event.keyCode == Instance.KeyCode;
if( scKeyStroke == true )
{
var field = Instance.Fields[ event.srcElement.id ];
if( isNullOrEmpty( field ) || isNullOrEmpty( field.DataValue ) )
return Instance.SBox.Hide();

Instance.SBox.Load( field );

var result = InitializeWordObject();
if( result != "" ) return alert( "ok" + result );

field.SpellingErrors = new SpellingErrors();

var textParts = field.DataValue.split( /\W/ );
var foundSuggestions = false;

for( var i = 0 ; i < textParts.length ; i++ )
{
var word = textParts[ i ].replace(/\W*/gi,"");
if( field.SpellingErrors.GetByValue( word ) != null )
continue;

try
{
activeDoc.Content = textParts[i];
activeDoc.LanguageDetected = false;
var range = activeDoc.Range(0,activeDoc.Range().End);
range.DetectLanguage();

if( range.SpellingErrors.Count > 0 )
{
var result = range.GetSpellingSuggestions();
if( result.Count > 0 )
{
var suggestion = new Suggestion(word);
for( var k = 1 ; k < result.Count ; k++ )
suggestion.AddOption( result.Item( k ).name );
field.SpellingErrors.Add( suggestion );
foundSuggestions = true;
}
}
}
catch( e )
{
Instance.Quite();
return alert( e.message );
}
}

if( !foundSuggestions ) Instance.SBox.Hide();
else Instance.SBox.UpdateUI( 0 );

return false;
}
}

/* Private Members */
function InitializeWordObject()
{
try
{
if( !isNullOrEmpty( mswordApp ) ) return "";
attachEvent( "onunload" , Instance.Quite );
mswordApp = new ActiveXObject( "Word.Application" );
mswordApp.Visible = false;
mswordApp.Application.Visible = false;
activeDoc = mswordApp.Documents.Add();
return "";
}
catch( wordErr )
{
Instance.Quite();
return wordErr.message;
}
}

/* Classes */
function Suggestion( word )
{
/* Public Fields */
this.Word = word;
this.Replacement = "";
this.Options = [];
this.IsReplacedOnce = false;
this.IsReplacedAll = false;

/* Public Methods */
this.AddOption = function( name ){
this.Options[ this.Options.length ] = name;
}
}

/* Collection of Spelling Errors Suggestions */
function SpellingErrors()
{
/* Public Fields */
this.Keys = [];
this.Vals = [];

/* Public Methods */
this.Add = function( suggestion )
{
if( this.Vals[ suggestion.Word ] == null )
{
this.Keys[ this.Keys.length ] = suggestion;
this.Vals[ suggestion.Word ] = suggestion;
}
}
/* Retrieve suggestion by word */
this.GetByValue = function( word ){
return this.Vals[ word ];
}
/* Retrieve suggestion by position */
this.GetByIndex = function( index )
{
if( this.Keys[ index ] != null )
return this.Vals[ this.Keys[ index ].Word ];
return null;
}
}

/* Suggestion Box Popup */
function SuggestionBox()
{
/* Private Fields */
var Box = this;
var SBoxPopup;

/* Public Fields */
Box.Field = null;

Box.Load = function( field )
{
Box.Field = field;

var loadingHTML = "<table height='100%' width='100%' style='cursor:wait'>";
loadingHTML += "<tr><td valign='middle' align='center'>";
loadingHTML += "<img alt='' src='/_imgs/AdvFind/progress.gif'/>";
loadingHTML += "<div/><b>Loading...</b>";
loadingHTML += "</td></tr>";
loadingHTML += "</table>";

SBoxPopup.document.all.divList.innerHTML = loadingHTML;

var Width = 241;
var Height = 140;
var Position = GetControlPostion(field);
var Left = Position.X + 1;
var Top = Position.Y + 1;
SBoxPopup.show( Left , Top , Width , Height , null );
}

/* Public Methods */

/* Hide the popup is it exists */
Box.Hide = function() {
if( SBoxPopup ) SBoxPopup.hide();
}
/* Clean popup control expando objects and html*/
Box.Clean = function()
{
with( SBoxPopup.document.all )
{
btnReplace.SelectedOption = null;
btnReplaceAll.SelectedOption = null;
spnReplace.innerHTML = "";
}
}
/* Update the current selection */
Box.UpdateSelection = function( index , newWord )
{
var suggestion = Box.Field.SpellingErrors.GetByIndex(index);
if( !isNullOrEmpty( suggestion ) )
{
suggestion.Replacement = newWord;
with( SBoxPopup.document.all )
{
spnReplace.innerHTML = "<B style='color:red'>" + newWord + "</B>";
btnReplace.SelectedOption = suggestion;
btnReplaceAll.SelectedOption = suggestion;
}
}
}
/* Update the Popup UI with current word selection */
Box.UpdateUI = function( index )
{
var spellingErrors = Box.Field.SpellingErrors;
if( !isNullOrEmpty( spellingErrors ) && spellingErrors.Keys.length > 0)
{
var create = SBoxPopup.document.createElement;
with( SBoxPopup.document.all )
{
divList.innerHTML = "";
var suggestion = spellingErrors.GetByIndex( index );
spnWord.innerText = suggestion.Word;
spnReplace.innerHTML = getReplacementHTML( suggestion );

for( var i = 0 ; i < suggestion.Options.length ; i++ )
{
var n = create( "SPAN" );
n.innerHTML = "&nbsp;" + ( i + 1 ) + ".&nbsp;";

var a = create( "<A style='width:85%;padding:1px'>" );
a.href = "#";
a.onclick = function(){ Box.UpdateSelection( index , this.innerText ); };
a.onmouseover = function(){ this.style.backgroundColor = 'gold' ; };
a.onmouseout = function(){ this.style.backgroundColor = 'white'; };
a.Box = Box;
a.innerText = suggestion.Options[ i ];

var br = create("<BR style='line-height:2px'>");

divList.appendChild( n );
divList.appendChild( a );
divList.appendChild( br );
}

var spLen = Box.Field.SpellingErrors.Keys.length;
var iPrev = index - 1 < 0 ? 0 : index - 1;
var iNext = index + 1 > spLen - 1 ? spLen - 1 : index + 1;

btnPrev.Box = Box;
btnNext.Box = Box;
btnReplace.Box = Box;
btnReplaceAll.Box = Box;

btnReplace.SelectedOption = null;
btnReplaceAll.SelectedOption = null;

btnPrev.onclick = function(){ this.Box.UpdateUI( iPrev ); }
btnNext.onclick = function(){ this.Box.UpdateUI( iNext ); }
btnReplace.onclick = function(){ this.Box.Replace( this ); }
btnReplaceAll.onclick = function(){ this.Box.ReplaceAll( this );}

var range = Box.Field.createTextRange();
var found = range.findText(suggestion.Word,1,2);
if( found )
{
range.expand(suggestion.Word);
range.select();
}
}
}
}
/* Replace a single occurance of the current error */
Box.Replace = function( btn )
{
var suggestion = btn.SelectedOption;
if( suggestion )
{
var re = new RegExp( "\\b" + btn.SelectedOption.Word + "\\b" , "i" );
var result = replace( btn , re );
suggestion.IsReplacedOnce = result != Box.Field.DataValue;
suggestion.IsReplacedAll = result == Box.Field.DataValue;
Box.Field.DataValue = result;
setReplacementHTML( suggestion );
}
else
{
setNullSuggestionHTML();
}
}
/* Replace all occurances of the current error */
Box.ReplaceAll = function( btn )
{
var suggestion = btn.SelectedOption;
if( suggestion )
{
var re = new RegExp( "\\b" + btn.SelectedOption.Word + "\\b" , "ig" );
var result = replace( btn , re );
suggestion.IsReplacedAll = true;
Box.Field.DataValue = result;
setReplacementHTML( suggestion );
}
else
{
setNullSuggestionHTML();
}
}

/* Private */

function replace( btn , re )
{
var suggestion = btn.SelectedOption;
if( !isNullOrEmpty( Box.Field.DataValue ) &&
!isNullOrEmpty( suggestion ) )
{
return Box.Field.DataValue.replace( re , suggestion.Replacement );
}
}

function GetControlPostion( control )
{
var Position = new Object();
var controlHeight = control.offsetHeight;
var iY = 0, iX = 0;
while( control != null )
{
iY += control.offsetTop;
iX += control.offsetLeft;
control = control.offsetParent;
}
Position.X = iX + screenLeft;
Position.Y = iY + screenTop + controlHeight;
return Position;
}

function Initialize()
{
SBoxPopup = window.createPopup();

var baseStyleUrl = "/" + ORG_UNIQUE_NAME + "/_common/styles";
with( SBoxPopup.document )
{
createStyleSheet( baseStyleUrl + "/global.css.aspx?lcid=" + USER_LANGUAGE_CODE );
createStyleSheet( baseStyleUrl + "/fonts.aspx?lcid=" + USER_LANGUAGE_CODE );
createStyleSheet( baseStyleUrl + "/controls.css.aspx?lcid=" + USER_LANGUAGE_CODE );
body.innerHTML = getSBoxHTML();
}
}

function getSBoxHTML()
{
var html = "";
html += "<DIV style='width:100%;height:100%;border:1px solid gray;background-color: #d8e8ff;filter: progid:DXImageTransform.Microsoft.Gradient(GradientType=0,StartColorStr=#eff3ff,EndColorStr=#84aed6);padding-left:2px;font:12 px tahoma'>";

html += "<TABLE cellpadding='1' cellspacing='1' style='width:100%'>";
html += "<TR>";
html += "<TD width='40%' height='20' valign='middle'>";
html += "<NOBR>";
html += "<IMG src='/_imgs/ico_mailmerge.gif' align='top'/>&nbsp;";
html += "<B><SPAN style='margin-top:5px' id='spnWord'></SPAN></B>";
html += "</NOBR>";
html += "</TD>";

html += "<TD>";
html += "<NOBR>";
html += "<IMG src='/_imgs/SetRegarding.gif' align='top'/>&nbsp;";
html += "<B><SPAN id='spnReplace'></SPAN></B>";
html += "</NOBR>";
html += "</TD>";

html += "<TR>";
html += "<TD>";
html += "<BUTTON id='btnPrev' class='ms-crm-Button' style='height:25'>Previous</BUTTON>";
html += "</TD>";
html += "<TD rowspan='4' width='65%' height='20'>";
html += "<DIV id='divList' style='overflow-y:scroll;width:95%;height:110;border:1px solid black;background-color:white'>";
html += "</DIV>";
html += "</TD>";
html += "</TR>";

html += "<TR>";
html += "<TD>";
html += "<BUTTON id='btnNext' class='ms-crm-Button' style='height:25'>Next</BUTTON>";
html += "</TD>";
html += "</TR>";

html += "<TR>";
html += "<TD>";
html += "<BUTTON id='btnReplace' class='ms-crm-Button' style='height:25'>Replace</BUTTON>";
html += "</TD>";
html += "</TR>";

html += "<TR>";
html += "<TD>";
html += "<BUTTON id='btnReplaceAll' class='ms-crm-Button' style='height:25'>Replace All</BUTTON>";
html += "</TD>";
html += "</TR>";

html += "</DIV>";
return html;
}

function getReplacementColor( suggestion ){
if( suggestion.IsReplacedAll ) return "green";
else if( suggestion.IsReplacedOnce ) return "orange";
else return "red";
}

function getReplacementHTML( suggestion ){
var color = getReplacementColor( suggestion );
return "<B style='color:" + color + "'>" + suggestion.Replacement + "</B>";
}

function setReplacementHTML( suggestion ){
SBoxPopup.document.all.spnReplace.innerHTML = getReplacementHTML( suggestion );
}

function setNullSuggestionHTML(){
SBoxPopup.document.all.spnReplace.innerHTML = "<B>Select a suggestion</b>";
}

Initialize();
}

//Utility
function isNullOrEmpty( o ){
return o == null || typeof( o ) == "undefined" || o == "";
}
}

OnCrmPageLoad();

27 comments:

Adi Katz said...

Hi everyone,

I tested the spell checker on a large chunk of data and it took to long Since the checker gets all the spelling suggestions in advance.

I inserted a small fix to make it go faster.

I’ll make a new revision next week, so the suggestion list is read from word one error at a time. That will eliminate the issue completely.

Cheers,
Adi

Adi Katz said...

Added a loading gif...

Anonymous said...

Hi Adi,

It seems to be a great work you did! Just a question: is there a way to specify which language to use (regarding a picklist value for example)?

Thanks, I will surely propose this spell checker to my prospect...

Adi Katz said...

Hi,

I’ve added line 89 and 91 which should enable word to automatically detect the language and change dictionaries.

I have remarked them since I don’t have proofing tools other then the default English dictionary which works fine.

So you can really check that it works. I suggest you try it and tell me how it went.

Adi

Anonymous said...

Hi,
Me again (even if marked as anonymous ;))

I tried your js on my french CRM...
It crashed but certainly not du to the french aspect...

The error occured line 88, saying:
activeDoc.Words is null or not an object

Do you have any idea to resolve it?

Anonymous said...

Me again...

I had, in fact, another error before the previous one: "An ActiveX component cannot create an object"

Tornado Divine said...

Many thanks for writing this, it looks great except I'm having a few problems getting this to work with our CRM.

If I add it to a phone call (checking on subject & description) the spell check box appears but remains on the loading gif. When I add this to the email entity, which is where I would really like this functionality, it doesn't work on the description field at all.

I've had to add our CRM url into trusted sites, as without it a javascript [object error] appears. Also all Active X permissions are set to 'enable'.

Do you have any suggestions as to why this might be happening?

many thanks

Adi Katz said...

Hi,

I made a few changes, copy the entire code again.

The first post worked on 2007 without a problem. I managed to reproduce both your errors on 2003 so I rewritten the way word handles the spell checking. The above code works both on 2003 and 2007. Test it now and tell me if you’re still having problems.

About other languages (dictionaries), word is now configured to pick up the correct language for every word.
If you have proofing tools installed you should be able to see suggestions in other languages.

And one last thing, you are right about IE. It needs to be added to the trusted sites and all aspects which relate to ActiveX should be enabled.

Best of luck.

Anonymous said...

Hi Adi,

This looks like a brilliant (and much needed) customization and really fills in a huge functionality gap in Microsoft CRM, well done!

I loaded the JavaScript into one of the CRM 4.0 VPC's to see it in action but while I can trigger it (and see the Loading gif), I always end up with the error "Command Failed".
I've added CRM to my Trusted Sites and all my ActiveX setting are enabled but still no luck; any idea on what I could be missing?

Thanks and well done again, this has been long saught after !!!

Adi Katz said...

Hi,

I suggest you take line 130 to 147 (InitializeWordObject function) and implement it as a stand alone function in a clean onload event.

If you see an error then the problem is with msword spell checker on the client machine.

If not, you’ll need to debug the failing line so I can get a clearer picture on what exactly is causing it.

Adi

Anonymous said...

I tried to implement your code but when launched I get a pop up window that says "okAutomation server can't create object"

I see your custom form loading but then the pop up.

Adi Katz said...

This mean that IE can not create the word.application activex object. Make sure the site is in the trusted sites and activex are allowed to run.

Anonymous said...

I tried to implement your code but it does not react to the key combination.

When i add a button thru isv.config and make some changes to the code so the button execute onSpellCheckingTest(), it is working.

Can you give me a hint why it does not react to the key combination (I changed the key combination to some other keys with no help...).

Anonymous said...

I found the problem with the key combination. I tried to use it with "email" entity, with attribute "description" and it does not work. with other attributs it does work. I have to find the "free" key combination that in that attribute...

Luiz Guimaraes said...

Hi Adi,

Your tool is fantastic. It is working fine for me with two exception.
1) I was getting Command Fail and it start working after I commented line 100.

2)The Alt Z does not work with the email description. I think it is related with the type of field. It is not a normal text field.

Do you know how to trigger the spellchecker in the email field?

Also, do you have any solution for the Notes in CRM?

Thanks a lot and again fantastic work.

Luiz

Adi Katz said...

Hi Luiz,

For the time being the spell checker only works with text fields (Input and Textarea elements).

Both notes and email description use iframes which this code does not handle in any way.

Adi

Anonymous said...

Where do I put this code. I put it on the onLoad event for e-mail and it just said errors on page when I hit alt-z. Please help clarify.

Thanks,

Matt

Adi Katz said...

Make sure you have msword installed on your machine, and that the CRM is in the trusted sites. What error are you receiving?

S said...

Hi. I've tried to use your code, but keep getting a pop up that says "okAutomation server can't create object".

I've added the site to trusted sites, and have allowed activex to be run. Do you have any other suggetions as to what I can do to make it work?

S said...

Adi,

Ignore my last comment. I found the setting that needed to be change under Internet Security.

Thanks.

Nick said...

Sorry but as a newbie to JS where do I add the fields that I want the spellcheker to check on?

Kind regards

Nick

Adi Katz said...

The entire code should be added to the entity onload event.

Line 8 describes the usage of adding fields to the spellchecker.

Some entities like email might already use the key combination that is used in this sample code. if the combination does not work try changing the combination or disable the ie add-in / toolbar that uses Alt + Z

Zhen said...

This client side script works great for me. I was looking at the c360 free spell checker, and it seems that way too complicated than this.
Good work!

Zhen said...

It is me (Zhen) again, I have it working on my computer, but it kept prompting "Command failed" on my co-workers' computers.

I have added it to the trusted site and enable all the "activex related" settings.

Anyone else have this problem?

Adi Katz said...

The configuration you mentioned is correct! and since this is working on another computer this might be a msword issue. Try re-installing / fixing word and see if it helps.

Anonymous said...

Hey Adi,

I think someone must of mentioned this issue before, but I have the "okAutomation server can't create object" message popping up. I've enabled all activex related setting and added to trust site. Do u think I could of leaving something out? Thanks in advance...

Anonymous said...

Hi Adi

Is there any way this could be used on an ntext field rather than an nvarchar?