Từ DesignMode đến 1 Rich Text Editor đơn giản

Đã có khá nhiều trình soạn thảo văn bản WYSIWYG miễn phí được share trên mạng, nhưng nếu bạn là một web developer đích thực, có thể một lúc nào đó bạn sẽ muốn xây dựng cho riêng mình một Rich Text Editor. Nếu vậy, bài viết này chắc hẳn sẽ đem lại cho bạn một chút gì đó hữu ích.

Thoạt tiên, khi nảy ra ý muốn viết 1 cái RTE, tôi đã nghĩ đến việc sử dụng 1 thẻ DIV để làm khung soạn thảo. Không khó trong việc giả lập con trỏ nhấp nháy, và chuyển các ký tự từ bàn phím vào trong phần tử DIV này.

Nhưng rồi sau nhiều cuộc tìm kiếm, tôi đã phát hiện ra 1 điều quan trọng khác giúp cho công việc tự đơn giản hóa đi rất nhiều, đó là: DesignMode.

DesignMode là 1 thuộc tính của đối tượng document, giá trị mặc định của nó là "off". Khi bạn sửa đổi giá trị này thành "on", hồ sơ web trở thành dạng có thể chỉnh sửa - editable, và bạn có thể thao tác với trang web trong cửa sổ như đang làm việc trên MS Word ! Đây chính là bí quyết để xây dựng nên những RTE cho các trình duyệt web.

Bây giờ chúng ta sẽ làm 1 trình soạn thảo WYSIWYG đơn giản, gồm một trường nhập text, và các nút lệnh cho phép định dạng văn bản in đậm - B, in nghiêng - I, gạch dưới - U. Ngoài ra, chúng ta cũng thêm vào 2 nút cho phép chèn hình ảnh và liên kết. Sau cùng là một nút để gỡ bỏ các định dạng. Giao diện của editor trông sẽ như thế này :

Figure 1

Khung soạn thảo là 1 iframe, như cách thường dùng của các RTE. Chúng ta cho nó 1 ID và dùng CSS để thiết lập kích thước và khả năng scroll:

<iframe id="textArea" style="width:500px;height:240px;overflow:auto;"></iframe>

Để biến vùng iframe này về dạng editable, chúng ta thiết lập giá trị của thuộc tính designMode thành on bằng script sau :

var editor = document.getElementById('textArea').contentWindow.document;
editor.designMode = 'On';
editor.open();
editor.write('<html><head></head><body></body></html>');
editor.close();

Đoạn code JavaScript này phải được gọi sau khi iframe đã hiện diện trong trang web.

contentWindow là 1 thuộc tính của các frames, iframes trong hồ sơ HTML.

document.getElementById('textArea').contentWindow cho phép tham chiếu đến đối tượng iframe mà định danh được thiết lập: ID = "textArea".

Trong Mozilla , chúng ta có thể tham chiếu đến 1 đối tượng cửa sổ iframe bằng script:

window.frames["myFrame"] 

Script tương ứng cho IE là:

document.all.myFrame.contentWindow 

Còn script mà chúng ta dùng sẽ thích hợp cho cả IE, Mozilla và Opera.

Như vậy, chúng ta có 1 biến toàn cục editor, để tham chiếu đến đối tượng document của phần tử iframe giả lập khung soạn thảo văn bản. Dòng lệnh tiếp theo thiết lập designMode của nó thành "on". Các lệnh open, write, close chèn vào iframe các tags cơ bản của 1 hồ sơ HTML, những gì nằm giữa <body></body> sẽ hiển thị trên khung soạn thảo như phần text mặc định của Editor.

Sau thiết lập designMode = "on", đối tượng document của iframe sẽ tự kích hoạt một số phương thức hỗ trợ thực hiện định dạng phần văn bản được chọn (selected text) bên trong nó. Chúng bao gồm:

queryCommandEnabled
queryCommandIndeterm
queryCommandState
queryCommandSupported
queryCommandValue

Trong đó, quan trọng hơn cả là 2 phương thức execCommandqueryCommandEnabled.

Syntax của execCommand như sau:

editableDocument.execCommand(sCommand [, bUserInterface] [, vValue])

Nếu không có text được chọn, phương thức này không làm gì cả.

Tham số bắt buộc sCommand là 1 chuỗi tên lệnh định dạng (command identifiers), chẳng hạn "bold", "italic"... QuirksMode liệt kê khá chi tiết những lệnh định dạng này, và cả cách chúng làm việc trong từng trình duyệt :

http://www.quirksmode.org/dom/execCommand.html

Tham số thứ hai, bUserInterface, thuộc dạng tùy chọn và mang 1 giá trị boolean (mặc định : false). Trong Mozilla, nếu đặt là true, bạn có thể nhận 1 lỗi (NS_ERROR_NOT_IMPLEMENTED).

Tham số thứ 3, vValue, cũng thuộc dạng tùy chọn. Bạn sẽ phải dùng đến nó trong một số trường hợp mà tham số đầu tiên cần 1 giá trị cụ thể. Chẳng hạn khi muốn định dạng màu chữ, tham số thứ nhất sẽ là "forecolor", và chúng ta cần tham số thứ 3 để cho trình duyệt biết màu gì sẽ được sử dụng. Ví dụ:

editableDocument.execCommand("forecolor", false, "#0000ff"); 

Hoặc khi chèn 1 hình ảnh, tham số thứ nhất sẽ là "insertimage", và tham số thứ 3 cho biết đường dẫn của hình ảnh đó:

editableDocument.execCommand('insertimage', false, imageURL);

Để biết 1 command có thể thực thi hay không, chúng ta dùng queryCommandEnabled. Phương thức này nhận 1 tham số là tên của command (như : "bold", "italic", "forecolor", "insertimage"...), và trả về 1 giá trị boolean cho biết khả năng thi hành command đó. Thông thường chúng ta kiểm tra command trước khi gọi nó. Script dưới đây kiểm tra command tạo liên kết, nếu có thể thực hiện thì sẽ chèn vào khung soạn thảo 1 HyperLink (aLink):

if (editableDocument.queryCommandEnabled("createlink")) {
    editableDocument.execCommand('createlink', false, aLink);
}

Bây giờ, chúng ta xem xét các button. Chúng là những hình ảnh mà bạn tùy ý trình bày sao cho hợp lý. Thông thường các button định dạng nằm ngay trên khung soạn thảo như trong ví dụ mẫu này. Các lệnh in đậm, in nghiêng, gạch dưới được liên kết với hàm doFormat qua sự kiện click :

<img src="bold.gif" onclick="doFormat('bold');">
<img src="italic.gif" onclick="doFormat('italic');">
<img src="underline.gif" onclick="doFormat('underline');"> 

Hàm doFormat như sau:

function doFormat(a, b) {     
  if (editor.queryCommandEnabled(a)) {       
    if (!b) {
      b = null;
    }        
    editor.execCommand(a, false, b);     
  }   
}

Như vậy, doFormat nhận vào 2 tham số :

  • a : tên của command
  • b : giá trị của command

Ở đây, editor là biến toàn cục đã định nghĩa bên ngoài hàm như trình bày phía trên. Khi được gọi, doFormat sử dụng phương thức queryCommandEnabled của editor để kiểm tra khả năng thực thi command a. Sau đó, tiếp tục kiểm tra tham số thứ 2, nếu không thấy thì gán cho nó giá trị null. Nếu có thể thực thi command thì execCommand.

Đối với việc chèn hình ảnh và liên kết, có một chút khác biệt, chúng ta viết 2 hàm addLink và insertImage dành riêng cho nhiệm vụ này. Chúng được gọi theo cách tương tự như 3 nút lệnh trước.

      <img src="image.gif" onclick="insertImage();">  
      <img src="link.gif" onclick="addLink();">

Trong IE, bạn có thể viết:

    editor.execCommand("CreateLink", true);

Hoặc:

   editor.execCommand("InsertImage", true);

Trình duyệt này sẽ hiển thị một hộp thoại để người sử dụng nhập vào các tham số thích hợp:

Hộp thoại chèn link trong IE

Hộp thoại chèn image trong IE

Tuy nhiên tính năng này chưa được Mozilla và Opera hỗ trợ, vì vậy, một số người lập trình Rich Text Editor đã tự xây dựng các kiểu DialogBox riêng để thay thế. Việc đó không khó, nhưng trong bài viết này, chúng ta sẽ đơn giản hóa công việc bằng 1 hộp thoại prompt.

Dưới đây là hàm addLink:

function addLink() {
  var aLink = prompt('Enter or paste a link :', '');
  if (aLink) {
    doFormat('CreateLink', aLink);
  }
}

Và hàm insertImage:

function insertImage() {
  document.getElementById('textArea').contentWindow.focus();
  var iURL = prompt('Enter or paste a URL :', '');
  if (iURL) {
    doFormat('InsertImage', iURL);
  }
}

Khi người sử dụng click trên nút Link, một hộp thoại prompt bật ra để họ nhập chuỗi siêu liên kết. Hàm addLink kiểm tra lại chuỗi này, nếu có giá trị thì gọi doFormat với 2 tham số : CreateLink - tên command, và aLink - URL đích do người dùng cung cấp.

Khi hàm insertImage được gọi, chúng ta sử dụng dòng lệnh đầu tiên để focus vào vùng soạn thảo. (Điều này là cần thiết cho trình duyệt IE, bạn thử bỏ đi và chạy thử chương trình thì sẽ hiểu tại sao !). Tiếp đó, 1 hộp thoại prompt mở ra yêu cầu người sử dụng nhập vào URL của hình ảnh. Cuối cùng, chúng ta gọi doFormat với command "InsertImage" và giá trị iURL mà người dùng đã nhập.

Nút lệnh Remove Formatting cho phép gỡ bỏ mọi định dạng trên phần text được chọn.

<img src="removeformatting.gif" onclick="unformat();">

Chúng ta viết hàm xử lý như sau:

function unformat() {
  doFormat('removeformat');
  doFormat('unlink');
}

Không có gì để giải thích nhiều. Command "removeformat" loại bỏ toàn bộ các thiết lập kiểu dáng văn bản. Command "unlink" gỡ bỏ liên kết cho phần văn bản đó.

Cuối cùng, chúng ta có toàn bộ mã HTML và JavaScript như dưới đây :

<h1>Rich Text Editor</h1>
<table border="0" cellpadding="0" cellspacing="1" bgcolor="#e1f2ff">
  <tr height="20">
    <td>
      <img src="bold.gif" title="Bold" onclick="doFormat('bold');">
      <img src="italic.gif" title="Italic" onclick="doFormat('italic');">
      <img src="underline.gif" title="Underline" onclick="doFormat('underline');">
      <img src="image.gif" title="Insert Image" onclick="insertImage();">
      <img src="link.gif" title="Hyperlink" onclick="addLink();">
      <img src="removeformatting.gif" title="Remove Formatting" onclick="unformat();">
    </td>
  </tr>
  <tr>
    <td align="center" bgcolor="#ffffff">
      <iframe id="textArea" style="width:500px;height:240px;overflow:auto;"></iframe>
    </td>
  </tr>
</table>
<script type="text/javascript">
  var editor = document.getElementById('textArea').contentWindow.document;
  editor.designMode = 'On';
  editor.open();
  editor.write('<html><head></head><body></body></html>');
  editor.close();

  function doFormat(a, b) {
    if (editor.queryCommandEnabled(a)) {
      if (!b) {
        b = null;
      }
      editor.execCommand(a, false, b);
    }
  }

  function addLink() {
    var aLink = prompt('Enter or paste a link :', '');
    if (aLink) {
      doFormat('CreateLink', aLink);
    }
  }

  function insertImage() {
    document.getElementById('textArea').contentWindow.focus();
    var aLink = prompt('Enter or paste a URL :', '');
    if (aLink) {
      doFormat('InsertImage', aLink);
    }
  }

  function unformat() {
    doFormat('removeformat');
    doFormat('unlink');
  }
</script>

Bây giờ thì Editor của chúng ta đã có thể hoạt động trên Mozilla, IE, Opera và Safari.

Cú pháp của execCommand

editableDocument.execCommand(sCommand [, bUserInterface] [, vValue])

Trong đó:

  • editableDocument: tên biến tham chiếu đến phần tử document của đối tượng iframe được dùng để giả lập khung soạn thảo.
  • sCommand: chuỗi tên command. Không phân biệt hoa - thường.
  • bUserInterface: biến tùy chọn hiển thị các DialogBox. Luôn thiết lập là false để tránh lỗi trong Mozilla và Opera.
  • vValue: giá trị cho command. Nếu command không cần chỉ định giá trị, sử dụng null.

Thông thường vValue là 1 chuỗi, ngoại trừ giá trị cho kích thước chữ có thể để dạng interger, nhưng nói chung vẫn nên đưa về kiểu string.

Những command thường dùng

Định dạng văn bản tại chỗ :

  • bold: in đậm text
  • fontname: font chữ, vValue :tên font. VD : arial, verdana...
  • fontsize: khổ chữ, vValue : các số từ 1 đến 7.
  • forecolor: màu chữ. vValue : chuỗi tên hoặc mã màu. VD : #0000ff, navy...
  • hilitecolor: màu nền (1). vValue : chuỗi tên hoặc mã màu. VD: #0000ff, navy...
  • italic: làm nghiêng text
  • subscript: text thấp xuống so với bình thường
  • superscript: đẩy text lên cao hơn bình thường
  • underline: gạch dưới text

Định dạng khối văn bản:

  • heading: Định dạng cho một tiêu đề. vValue : <h1>, <h2>, <h3>, <h4>, <h5>, <h6>.
  • indent: cho khối văn bản lui vào 1 tab
  • insertorderedlist: liệt kê theo số thứ tự
  • insertunorderedlist: liệt kê không đánh số.
  • justifycenter: căn giữa
  • justifyfull: dàn đều 2 biên
  • justifyleft: căn trái
  • justifyright: căn phải
  • outdent: cho khối văn bản lui ra 1 tab

Các command đặc thù khác:

  • createlink: tạo liên kết. vValue : chuỗi URL
  • delete: xóa phần selection
  • inserthorizontalrule: chèn vào 1 phần tử <hr>
  • inserthtml: chèn vào 1 chuỗi HTML (2)
  • insertimage: chèn hình ảnh. vValue : đường dẫn đến file ảnh.
  • removeformat: loại bỏ các định dạng ở phần selection.
  • unlink: loại bỏ liên kết ở phần selection.

Các command điều khiển toàn cục:

  • undo: khôi phục lại tình trạng trước khi bị thay đổi (3)
  • redo: khôi phục lại tình trạng trước khi undo 1 bước
  • selectall: chọn toàn bộ nội dung editor.

Danh sách trên chưa đầy đủ, nhưng là những gì cơ bản nhất cho 1 trình soạn thảo văn bản. Chúng là các commands nhận được sự hỗ trợ chung của nhiều trình duyệt.

Notes:

  1. Với IE, cần thay bằng command backcolor.
  2. Chỉ duy nhất Mozilla hỗ trợ command này. Với IE, có thể dùng các command cụ thể cho từng đối tượng muốn chèn, như : InsertInputButton, InsertTextArea, InsertMarquee... Xem thêm tại đây.
  3. Các thay đổi được ghi nhận có thể là bất cứ hành động nào làm khác đi nội dung vốn có trong Editor, chẳng hạn như việc gõ vào, xóa đi, hay đổi màu 1 ký tự... Undo và Redo chỉ làm việc hoàn hảo trong Mozilla.

Viết class cho editor

Khi nắm được những khái niệm xung quanh thuộc tính designMode và phương thức execCommand, việc xây dựng Rich Text Format Editor không còn là điều khó khăn nữa. Có lẽ những bạn nào mà qua topic này, lần đầu tiên nghe nói đến các khái niệm trên, sẽ có cùng cảm giác như vậy. Hiểu được vấn đề là giải quyết được vấn đề.

Các trình WYSIWYG xây dựng sẵn thường khá cồng kềnh. Chúng càng cố khoác lên mình dáng vẻ của các ứng dụng soạn thảo văn bản desktop thì hình thức càng rườm rà, bề bộn, với những tính năng mà có thể chúng ta không bao giờ dùng đến.

Đây là một số trình soạn thảo WYSIWYG phổ biến:

Các Editor cũng giống như mọi ứng dụng nền web, đều có hạn chế là phải phụ thuộc vào mức độ hỗ trợ của các trình duyệt. Một ứng dụng web có thể hoạt động trong nhiều trình duyệt khác nhau, không có lỗi khi chạy, và ít rắc rối, thì dễ gây cảm tình hơn những trò hoa lá.

Về phía những editor mà các dịch vụ web sử dụng thì cái của Gmail hợp với mỹ cảm của tôi hơn cả. Ở đó có sự hài hòa giữa tính đơn giản, vẻ đẹp trang nhã và khả năng hoạt động chuẩn xác, hoàn hảo.

Do bài viết này chỉ nhằm mục đích giới thiệu cách xây dựng trình soạn thảo WYSIWYG, nên chúng ta vẫn giữ nguyên hình thức của bản trước mà không cần thêm các nút lệnh định dạng khác trên toolBar, tránh làm phức tạp vấn đề.

Chúng ta mở đầu bằng đoạn script:

var RTE = null;
var isIE = document.all ? true : false;
var isMz = (navigator.appName == 'Netscape') ? true : false;

function rel(id) {
  return document.getElementById(id) || null;
}

function setHTML(el, sHTML) {
  if (rel(el)) {
    rel(el).innerHTML = sHTML;
  } else {
    alert('The element "' + el + '" not found !');
  }
}

function setBtnAction(btnID, Action) {
  var el = rel(btnID);
  if (el.addEventListener) {
    el.addEventListener('click', new Function(Action), false);
  } else if (el.attachEvent) {
    el.attachEvent('onclick', new Function(Action));
  }
}

rel trả về phần tử dựa trên id của nó. setHTML có nhiệm vụ gán code HTML vào 1 phần tử nào đó. setBtnAction chỉ định 1 hành động cho 1 button dưới sự kiện click. Hàm này nhận vào 2 tham số: ID của button và chuỗi tên hành động. Ví dụ chúng ta có button btnBold hiện diện dưới dạng 1 icon nhỏ:

<img id="btnBold" src="bold.gif" title="Bold">

Nếu muốn chương trình thực thi lệnh doFomat("bold"), khi click vào hình ảnh này, chúng ta chỉ việc gọi hàm:

setBtnAction('btnBold', 'doFormat("bold")'); 

Khi chuỗi 'doFormat("bold")' được truyền vào hàm setBtnAction, vì addEventListener và attachEvent bắt buộc tham số thứ 2 phải là 1 hàm, nên setBtnAction sẽ dùng chuỗi này để tạo ra 1 đối tượng Function với toán tử new.

Giữ nguyên các thành phần điều khiển Editor như cũ, chúng ta viết lại code HTML:

<table border="0" cellpadding="0" cellspacing="1" bgcolor="#e1f2ff">
  <tr height="20">
    <td>
      &nbsp;
      <img id="btnBold" src="bold.gif">
      <img id="btnItalic" src="italic.gif">
      <img id="btnUnderline" src="underline.gif">
      <img id="btnInsertImage" src="image.gif">
      <img id="btnAddLink" src="link.gif">
      <img id="btnUnformat" src="removeformatting.gif">
    </td>
  </tr>
  <tr>
    <td align="center" bgcolor="#ffffff">
      <span id="wysiwyg"></span>
    </td>
  </tr>
</table>

Thay vì nhúng các hàm vào từng button, chúng ta đã cho mỗi hình ảnh một giá trị ID.

Nếu hình dung điều khiển WYSIWYG của chúng ta như 1 đối tượng, nó có thể mang các thuộc tính cơ bản như: tên, kích thước vùng làm việc, nội dung bên trong.

Và bên cạnh đó là 1 loạt các phương thức thi hành việc định dạng văn bản: chèn link, chèn hình ảnh...

Đây là hàm dựng:

function RichTextEditor(sID, oContain, sDefaultText, iWidth, iHeight) {

  this.ID = sID;
  this.content = sDefaultText;
  this.width = (iWidth > 300 ? iWidth : 500);
  this.height = (iHeight > 80 ? iHeight : 200);

  var sEditor = '&lt;iframe id="' + this.ID;
  sEditor += '" style="width:' + this.width;
  sEditor += 'px;height:' + this.height;
  sEditor += 'px;overflow:auto;"&gt;&lt;/iframe&gt;';

  setHTML(oContain, sEditor);

  this.UI = document.getElementById(this.ID).contentWindow;
  this.editor = this.UI.document;

  with(this.editor) {
    designMode = 'On';
    open();
    write('<html><head></head><body>' + this.content + '</body></html>');
    close();
  }
}

Các tham số :

  • sID: chuỗi tên riêng cho mỗi Editor, bằng cách này, chúng ta có thể sử dụng nhiều Editor trên cùng 1 trang web.
  • oContain: phần tử chứa Editor mà khung soạn thảo sẽ được tạo ra bên trong.
  • sDefaultText: văn bản mặc định hiển thị khi Editor mở ra. Đây là 1 chuỗi HTML.
  • iWidth: chiều rộng của khung soạn thảo.
  • iHeight: chiều cao của khung soạn thảo.

Trong hàm dựng, chúng ta thiết kế các thuộc tính:

  • ID: định danh riêng
  • content: nội dung văn bản trong editor
  • width: chiều rộng
  • height: chiều cao
  • UI: tham chiếu đến khung iframe
  • editor: tham chiếu đến đối tượng document của khung iframe. Biến này trực tiếp thi hành các command.

Hàm doFormat ở phiên bản cũ sẽ trở thành phương thức formatText của lớp đối tượng RichTextEditor:

RichTextEditor.prototype.formatText = function(command, value) {
  if (this.editor.queryCommandEnabled(command)) {
    if (!value) {
      value = null;
    }
    this.editor.execCommand(command, false, value);
  }
}

Và tương tự cho addLink, insertImage, unformat:

RichTextEditor.prototype.addLink = function() {
  var aLink = prompt('Enter or paste a link :', '');
  if (aLink) {
    this.formatText('CreateLink', aLink);
  }
}
RichTextEditor.prototype.insertImage = function() {
  this.UI.focus();
  var aLink = prompt('Enter or paste a URL :', '');
  if (aLink) {
    this.formatText('InsertImage', aLink);
  }
}
RichTextEditor.prototype.unformat = function() {
  this.formatText('removeformat');
  this.formatText('unlink');
}

Ngoài ra chúng ta thêm vào 1 phương thức mới là getContent để lấy nội dung văn bản:

RichTextEditor.prototype.getContent = function() {
  return this.editor.body.innerHTML;
}

Cuối cùng là 1 hàm dùng để khởi tạo trình soạn thảo, được gọi sau khi trang load xuống hoàn chỉnh:

function init() {

  RTE = new RichTextEditor('RTE', 'wysiwyg', '', 500, 240);

  setBtnAction('btnBold', 'RTE.formatText("bold")');
  setBtnAction('btnItalic', 'RTE.formatText("italic")');
  setBtnAction('btnUnderline', ' RTE.formatText("underline");');
  setBtnAction('btnInsertImage', 'RTE.insertImage();');
  setBtnAction('btnAddLink', 'RTE.addLink();');
  setBtnAction('btnUnformat', ' RTE.unformat();');
}

Biến RTE được dùng làm một thể hiện của lớp RichTextEditor. Các dòng sau thiết lập hành động cho từng nút lệnh trên toolBar.

init được gọi trong sự kiện onload:

window.onload = init ; 

Và thế là chúng ta đã có phiên bản Rich Text Editor mới, tuy không thay đổi trên giao diện, nhưng cấu trúc chương trình đã trở nên chặt chẽ hơn hẳn.

Từ bản demo rút gọn đến sản phẩm sau cùng chỉ còn một quãng đường ngắn, và chắc hẳn bạn sẽ cảm thấy dễ chịu hơn nếu đi tới đó một mình :)

Xử lý các command copy, cut và paste

Clipboard là một vùng nhớ tạm trong window, nơi chứa dữ liệu mà chúng ta đã copy hay cut. Các ứng dụng khác nhau vẫn được phép sử dụng chung khối dữ liệu này. Có nghĩa là có thể bạn copy text trong MS Word, sau đó dán lên khung soạn thảo của Google Docs.

Các command "copy", "cut", "paste" cho phép RTE của chúng ta làm điều này. Nhưng vì lý do an toàn, ngoại trừ IE, các trình duyệt khác đều mặc định vô hiệu hóa tính năng trên.

Nếu Java Script cố gọi thử lệnh này trên Mozilla, Eror Console sẽ trả về lỗi NS_ERROR_DOM_XPCONNECT_ACCESS_DENIED.

Google Docs bắt lỗi này lại và cho ra thông báo:

Figure 1

Nhiều phương pháp đã được đưa ra để khắc phục trở ngại này, đồng thời tránh khỏi việc phải yêu cầu người dùng thay đổi cấu hình trên hệ thống của họ. Một trong những phương pháp khá thú vị là sử dụng Action Script. Do ngôn ngữ này có thể can thiệp sâu vào hệ thống của client nên 1 chương trình flash đã được viết ra để bổ sung khả năng vào nơi mà Java Script bất lực. (Xem chi tiết)

Nhưng nếu bạn từng sử dụng Zoho Writer, bạn sẽ thấy họ vẫn làm cho các lệnh copy, cut và paste hoạt động được trên FireFox. Lần tìm giải pháp trong source code của một ứng dụng web phức tạp như Zoho Writer là một việc không mấy dễ dàng. Chỉ còn cách dựa trên các biểu hiện để phán đoán.

Điều dễ nhận thấy là chúng ta không thể đem paste khối văn bản được copy hay cut từ khung soạn thảo của Zoho Writer vào MS Word, và ngược lại, cũng không thể dùng lệnh paste trên khung soạn thảo của Zoho Writer để chèn vào nó những gì có trong clipboard của windows. Từ đó cho thấy nội dung chỉ được lưu trong 1 biến tạm của kịch bản javascript mà Zoho Writer sử dụng.

Tuy nhiên, cách làm của Zoho Writer chưa hoàn toàn thuyết phục. Điểm hạn chế của nó là không ngăn cản được sự "merge style". Xem hình minh họa bên dưới:

Figure 2

Đầu tiên tôi chọn đoạn text "thuộc tính designMode", nhấn lệnh Copy bên trên toolBar, rồi đặt con trỏ vào cuối dòng tiêu đề - "Viết class cho editor", sau đó nhấn Paste. Kết quả là chuỗi được paste sẽ mang định dạng của chuỗi tiêu đề bài viết mà không giữ nguyên kiểu dáng của nó. Nếu bạn quen dùng MS Word thì điều này thật đáng bực mình.

Chúng ta sẽ giải quyết vấn đề một cách triệt để hơn Zoho. Ý tưởng cơ bản là tạo ra 1 bản sao chính xác của phần text được chọn, lưu nó vào một biến tạm. Biến này đóng vai trò clipboard, được xem như 1 thuộc tính của lớp RichTextEditor.

Với mỗi command, chúng ta sẽ có cách xử lý tương ứng:

  • copy : tạo bản sao của selection, lưu vào biến tạm.
  • cut : copy rồi xóa đi phần text selection.
  • paste : gắn chuỗi trong biến tạm vào lại editor.

Để thực hiện ý tưởng, trước hết hãy tìm hiểu đôi chút về Selection và Range.

Range là một phần nội dung trong hồ sơ HTML, thường liên quan đến phần text được highlight bởi người sử dụng. Đối tượng này mô tả tất cả nội dung giữa điểm đầu và điểm cuối. Với các phương thức và thuộc tính của đối tượng này, chúng ta có thể làm khá nhiều việc với phần văn bản đang ở trạng thái selected. Cùng một thời điểm, có thể tồn tại nhiều đối tượng Range.

Selection là đối tượng chỉ phần text đang trong trạng thái selected và đang được active. Nó cung cấp thông tin về văn bản và toàn bộ các phần tử nằm trong phần text đang được chọn. Không hoàn toàn giống với Range, Selection liên quan đến các đối tượng văn bản, như : từ, câu, đoạn... Trong 1 thời điểm chỉ có thể tồn tại 1 Selection.

Điểm khác biệt giữa Range và Selection khá tinh tế. Lấy 1 minh họa như hình dưới đây cho dễ hiểu :

Figure 3

Nếu bạn tạo 1 Selection từ phần được chọn, đối tượng này sẽ phản ánh chuỗi "ject</i> to modify the document <b>select". Trong khi Range phân tích cấu trúc DOM của hồ sơ và cho biết cụ thể đoạn "ject" nằm trong phần tử <i>, đoạn "select" nằm trong phần tử <b>, và toàn bộ selection cùng thuộc phần tử <BODY>.

Chính vì vậy, việc sử dụng Range cho phép chúng ta không những lấy ra phần text selection mà cả các phần tử chứa nó - các tag HTML, nghĩa là text selection có kèm theo định dạng

Với Opera và Mozilla, bạn có thể tạo Range bằng phương thức createRange của đối tượng document, hoặc phương thức getRangeAt của đối tượng Selection. Khi chọn 1 đoạn text bất kỳ trên trang web, bạn có thể lấy nội dung phần được chọn bằng phương thức getSelection của window. Phương thức này cũng đồng thời tạo ra một đối tượng Selection.

Range có khá nhiều thuộc tính và phương thức. Danh sách đầy đủ bạn có thể xem tại đây. Trong số này chúng ta sẽ cần đến 2 phương thức extractContentscloneContents.

cloneContents trả về 1 bản sao chính xác của range hiện thời, tức là phần text được chọn. extractContents cũng tương tự cloneContents, nhưng loại bỏ range gốc. Kết quả trả về từ 2 phương thức này có dạng 1 đối tượng documentFragment. Tới đây chúng ta rơi vào lãnh địa của XML và DOM.

Vì documentFragment giống như mọi phần tử trong 1 hồ sơ XML, chúng ta sẽ dùng phương thức serializeToString của đối tượng XMLSerializer được các trình duyệt tuân thủ chuẩn W3C hỗ trợ, để chuyển phần hồ sơ XML đó về dạng chuỗi thông thường.

Note :
Ngược lại với serializeToString của đối tượng XMLSerializer là phương thức parseFromString của đối tượng DOMParser, cho phép bạn thao tác trên 1 chuỗi text bình thường như với một tập hợp node trong hồ sơ XML.

Chẳng hạn bạn có chuỗi s='<result>125789</result>', đây không phải là dữ liệu trong 1 hồ sơ XML nên bạn không thể xử lý result như phần tử XML. Và bạn cần parseFromString để làm điều đó.

Trở lại với công việc chính, theo logic chương trình như trên, chúng ta thêm vào class RichTextEditor 1 thuộc tính có tên là clipBoard :

function RichTextEditor(sID, oContain, sDefaultText, iWidth, iHeight){
  this.ID=sID;
  this.content=sDefaultText;
  this.width=(iWidth>300?iWidth:500);
  this.height=(iHeight>80?iHeight:200);
  this.clipBoard='';
   .... 

Và phương thức useClipboard được định nghĩa như dưới đây :

RichTextEditor.prototype.useClipboard=function(command){
  if(isIE){
    this.format(command);
  }   
  else{
    if(command=='cut'||command=='copy'){
      var sel = this.UI.getSelection();
      if(sel==''){return;}
      var range = sel.getRangeAt(0);
      var docFrag = (command=='cut')?range.extractContents():range.cloneContents();
      var xmls = new XMLSerializer();
      this.clipBoard=xmls.serializeToString(docFrag);   
    }
    else if(command=='paste'){
      if(this.clipBoard!=''){
        this.format('inserthtml',this.clipBoard);
      }
    }
  }
  this.UI.focus();
} 

useClipboard kiểm tra xem trình duyệt có phải IE không, nếu là IE thì thực hiện command bình thường. Ngược lại, bắt đầu phân tích command.

Với copy và cut, chúng ta tạo ra Selection:

var sel = this.UI.getSelection();

Từ đối tượng Selection này, chúng ta tạo Range bằng phương thức getRangeAt(0). Ở đây, tham số 0 ám chỉ Range đầu tiên trong tập hợp Range.

var range = sel.getRangeAt(0); 

Và clone của nó ở dạng documentFragment. Nếu là cut thì sử dụng extractContents, nếu là copy thì sử dụng cloneContents :

var docFrag = (command=='cut')?range.extractContents():range.cloneContents(); 

Cuối cùng , chúng ta khởi tạo XMLSerializer và chuyển chuỗi vào clipboard của Editor.

var xmls = new XMLSerializer();
this.clipBoard = xmls.serializeToString(docFrag); 

Với paste, chúng ta dùng command inserthtml để chèn chuỗi từ clipboard vào editor :

this.format('inserthtml', this.clipBoard); 

Thế là vấn đề đã được giải quyết xong. Bây giờ hãy thêm vào toolbar các lệnh Copy, Cut và Paste :

<img id="btnCut" src="cut.gif" title="Cut"> 
<img id="btnCopy" src="copy.gif" title="Copy">
<img id="btnPaste" src="paste.gif" title="Paste">

Và thêm thiết lập action trong hàm init :

 setActionOnButton('btnCut', 'RTE.useClipboard("cut");');
 setActionOnButton('btnCopy', 'RTE.useClipboard("copy");');
 setActionOnButton('btnPaste', 'RTE.useClipboard("paste");'); 

Như vậy, vấn đề đã được giải quyết tương đối trọn vẹn.

Kết luận

Trong bài viết này tôi đã giới thiệu cách xây dựng một trình soạn thảo văn bản WYSIWYG đơn giản bằng JavaScript, sử dụng thuộc tính designMode và phương thức execCommand. Mặc dù vẫn còn những hạn chế nhất định, nhưng đây là một khởi đầu tốt để bạn có thể tự mình phát triển một trình soạn thảo văn bản mạnh mẽ và phù hợp với nhu cầu của mình.