本文重點

  • 和往常一樣,我與 ChatGPT 兩人三腳──嚴格說我幾乎只是負責下指示的人──想打造 SVG 編輯器/zh-hant/tools/svgedit/,結果一路跌跌撞撞。這篇整理所有的卡關點。
  • 我一個人絕對做不到。生成式 AI 的寫程式能力確實驚人。
  • 但也不是「交給 AI 就會完成」。必須由我來統籌全貌、修正需求誤解、決定排障方向,徹頭徹尾扮演行動型主管。
  • 從這層意義看,這次經驗也提供了與 AI 共事時如何生存的提示。

只用瀏覽器的 SVG 編輯器:實作與規格解說

什麼是 SVG?

SVG(Scalable Vector Graphics,可縮放向量圖形)是 W3C 制定的 XML 為基礎的向量影像格式。與 PNG、JPEG 等點陣圖不同,放大或縮小都不會失真。此外,因為 SVG 可以作為 DOM 的一部分直接嵌入 HTML 文件,因此能透過 JavaScript 與 CSS 進行動態操作與套用樣式。規格遵循 W3C SVG 規格,具備圖形、文字、路徑、漸層、濾鏡等強大表現力。

SVG 廣泛應用於下列情境:

  • 網頁上的圖示與圖表顯示
  • 動態產生流程圖或圖解
  • 具備使用者互動的圖形繪圖應用程式
  • 製作印刷品或 UI 設計素材

本次 SVG 編輯器概要

這款編輯器是完全在瀏覽器內運作的純前端工具。使用者不僅能繪製矩形、橢圓等基本圖形,還能畫出星形、對話泡泡、雲朵、愛心,以及利用筆模式描繪自由曲線。圖形可移動、縮放、旋轉,介面也支援對齊、貼齊(snap)、群組與圖層列表。完成的圖可以匯出為 SVG 或 PNG。

以下說明如何把 SVG 規格轉寫成 JavaScript 程式,以及中途踩過哪些坑。

在實作中反映規格時遇到的難題

新增圖形與屬性管理

SVG 規格為各種圖形定義了對應的元素與屬性。矩形使用 <rect>,橢圓使用 <ellipse>,多邊形使用 <polygon>,並且要正確設定 xywidthheightcxcyrxrypoints 等屬性。像星形或愛心這類複雜圖形則以 <path> 元素結合貝茲曲線與圓弧指令生成。

實作範例(新增矩形)

function addRectangle(x, y, width, height) {
  const rect = document.createElementNS("http://www.w3.org/2000/svg", "rect");
  rect.setAttribute("x", x);
  rect.setAttribute("y", y);
  rect.setAttribute("width", width);
  rect.setAttribute("height", height);
  rect.setAttribute("fill", "transparent");
  rect.setAttribute("stroke", "#000");
  svg.appendChild(rect);
}

矩形、橢圓很單純,但多邊形與客製形狀必須計算座標。以星形為例,先把中心角等分,再計算頂點並寫入 points 屬性。

function createStar(cx, cy, spikes, outerR, innerR) {
  let points = "";
  const step = Math.PI / spikes;
  for (let i = 0; i < 2 * spikes; i++) {
    const r = i % 2 === 0 ? outerR : innerR;
    const x = cx + r * Math.cos(i * step);
    const y = cy + r * Math.sin(i * step);
    points += `${x},${y} `;
  }
  const polygon = document.createElementNS("http://www.w3.org/2000/svg", "polygon");
  polygon.setAttribute("points", points.trim());
  polygon.setAttribute("stroke", "#000");
  polygon.setAttribute("fill", "transparent");
  svg.appendChild(polygon);
}

變形與座標系

SVG 可以透過 transform 屬性組合 translaterotatescale 等變形。棘手的是,選取圖形的外框時必須彌補本地座標與全域座標的差異。為了解決這點,我利用 getScreenCTM() 轉換成全域座標後再計算外接矩形。

套用座標變換

function getTransformedBBox(element) {
  const bbox = element.getBBox();
  const matrix = element.getScreenCTM();
  const points = [
    svg.createSVGPoint(), svg.createSVGPoint(),
    svg.createSVGPoint(), svg.createSVGPoint()
  ];
  points[0].x = bbox.x; points[0].y = bbox.y;
  points[1].x = bbox.x + bbox.width; points[1].y = bbox.y;
  points[2].x = bbox.x; points[2].y = bbox.y + bbox.height;
  points[3].x = bbox.x + bbox.width; points[3].y = bbox.y + bbox.height;
  return points.map(p => p.matrixTransform(matrix));
}

如此一來,即便套用了旋轉或縮放,也能畫出正確的選取框。

貼齊功能與格線

格線使用 SVG 的 <pattern> 元素描繪在背景。貼齊(snap)則由 JavaScript 計算座標並四捨五入到指定間距。

function snapToGrid(value, gridSize) {
  return Math.round(value / gridSize) * gridSize;
}

物件貼齊會掃描其他圖形的中心與端點,找出最近點並顯示輔助線,讓圖形吸附對齊。這大幅提升了操作精度。

文字編輯

由於 SVG 的 <text> 元素無法直接編輯,我改採在上方暫時疊一個 HTML <input> 的方式。

function editTextElement(textElem) {
  const input = document.createElement("input");
  input.type = "text";
  input.value = textElem.textContent;
  document.body.appendChild(input);
  const bbox = textElem.getBoundingClientRect();
  input.style.position = "absolute";
  input.style.left = bbox.x + "px";
  input.style.top = bbox.y + "px";
  input.focus();
  input.addEventListener("blur", () => {
    textElem.textContent = input.value;
    document.body.removeChild(input);
  });
}

路徑與自由曲線

SVG 的 <path> 可以組合 M、L、C、A 等指令來繪圖。我先從直線為主的編輯著手,每次點擊就追加座標。

let pathData = "M100,100";
function addPathPoint(x, y) {
  pathData += ` L${x},${y}`;
  path.setAttribute("d", pathData);
}

匯出(SVG/PNG)

儲存時會先移除輔助元素再匯出。PNG 轉換透過 Canvas 進行。

function exportToPNG(svgElement) {
  const serializer = new XMLSerializer();
  const source = serializer.serializeToString(svgElement);
  const img = new Image();
  const svgBlob = new Blob([source], { type: "image/svg+xml;charset=utf-8" });
  const url = URL.createObjectURL(svgBlob);
  img.onload = () => {
    const canvas = document.createElement("canvas");
    canvas.width = img.width;
    canvas.height = img.height;
    const ctx = canvas.getContext("2d");
    ctx.drawImage(img, 0, 0);
    canvas.toBlob(blob => {
      const a = document.createElement("a");
      a.href = URL.createObjectURL(blob);
      a.download = "drawing.png";
      a.click();
    });
  };
  img.src = url;
}

畫布的卷軸支援

若要處理大型 SVG,就用 <div style="overflow:auto"> 把畫布包起來,必要時顯示捲軸即可。

總結

SVG 的規格雖然強大,但實作上需要跨越座標轉換、路徑定義等高門檻。這套編輯器整理後達成了:

  • 操作圖形元素屬性(rect、ellipse、polygon、path)
  • 正確處理 transform 與座標系
  • 實作格線與貼齊功能
  • 支援行內文字編輯
  • 路徑編輯(以直線為主)
  • 同時支援 PNG/SVG 匯出
  • 大畫面可滾動操作

充分發揮了 SVG 的表現力,打造出只需瀏覽器就能運行的高機能編輯器。