moonlight

Lightweight SVG editor by mizchi/luna, like Excalidraw

js
luna
moon add mizchi/moonlight@0.1.2
Download zip
Author
Version
0.1.2
License
MIT
Last updated
6 months ago
Downloads
25
README

#Moonlight

A lightweight SVG editor built with MoonBit and the Luna reactive UI library. Inspired by Excalidraw, Moonlight provides a simple yet powerful interface for creating and editing vector graphics.

#Demo

Live Demo: https://moonlight.mizchi.workers.dev

Try the editor directly in your browser. No installation required.

#Features

  • SVG-native: Works directly with SVG elements (rect, circle, ellipse, line, text)
  • Multiple modes: Full editor, embeddable widget, and Web Component
  • Reactive UI: Built on Luna's signal-based reactivity
  • Undo/Redo: Full command history support
  • Grid snapping: Optional snap-to-grid for precise alignment
  • Connected lines: Lines can connect to element anchor points and follow when moved
  • Dark/Light themes: Toggle between dark and light modes
  • Import/Export: Save and load SVG files

#Getting Started

#Prerequisites

#Installation

git clone <repository-url> cd moonlight pnpm install

#Development

# Start development server pnpm dev # Type checking moon check # Format code moon fmt # Build for production pnpm build

#Access the Editor

  • Full Editor: http://localhost:5173/
  • Embed Mode: http://localhost:5173/embed.html
  • Web Component: http://localhost:5173/webcomponent.html

#User Guide

#Tools

ToolShortcutDescription
SelectV or 1Select and move elements
RectangleR or 2Draw rectangles
CircleC or 3Draw circles/ellipses
LineL or 4Draw lines and arrows
TextT or 5Add text elements

#Keyboard Shortcuts

#General

ShortcutAction
Ctrl+Z / Cmd+ZUndo
Ctrl+Shift+Z / Cmd+Shift+ZRedo
Ctrl+Y / Cmd+YRedo (alternative)
Delete / BackspaceDelete selected element
EscapeDeselect / Cancel operation

ShortcutAction
Arrow KeysMove selected element (5px)
Shift + Arrow KeysMove selected element (1px, precise)
Ctrl + Arrow KeysMove selected element (10px, fast)

#Element Operations

ShortcutAction
Ctrl+D / Cmd+DDuplicate selected element
[Send backward
]Bring forward
Ctrl+[ / Cmd+[Send to back
Ctrl+] / Cmd+]Bring to front

#View

ShortcutAction
GToggle grid visibility
Ctrl+G / Cmd+GToggle grid snapping

#Mouse Operations

  • Click: Select element
  • Click + Drag: Move element or draw new shape
  • Click on canvas: Deselect all
  • Right-click: Context menu (delete, duplicate, layer ordering)

#Working with Lines

Lines can be connected to other elements:

  1. Select the Line tool (L)
  2. Click near an element's anchor point (edges or center)
  3. Drag to another element's anchor point
  4. The line will automatically follow when connected elements are moved

#Detail Panel

The right sidebar shows properties of the selected element:

  • Position: X/Y coordinates (editable)
  • Size: Width/Height or radius (editable)
  • Style: Fill color, stroke color, stroke width
  • Line markers: Add arrows to line endpoints

#Themes

Toggle between light and dark themes using the theme button in the toolbar.

#Usage Modes

#1. Full Editor Mode

The standalone editor with all features enabled.

<script type="module" src="./main.ts"></script>

#2. Embed Mode

Embed the editor in your application using JavaScript:

<script type="module" src="./embed.ts"></script> <div id="editor-container"></div> <script> const editor = MoonlightEditor.create( document.getElementById('editor-container'), { width: 800, height: 600, theme: 'light', // or 'dark' readonly: false } ); // API const svg = editor.exportSvg(); editor.importSvg(svgString); editor.clear(); editor.hasFocus(); </script>

#3. Web Component Mode

Use as a custom HTML element:

<script type="module" src="./webcomponent.ts"></script> <moonlight-editor width="800" height="600" theme="light"> </moonlight-editor> <script> const editor = document.querySelector('moonlight-editor'); // API (same as embed mode) const svg = editor.exportSvg(); editor.importSvg(svgString); editor.clear(); editor.hasFocus(); </script>

#Attributes

AttributeTypeDefaultDescription
widthnumber400Canvas width in pixels
heightnumber300Canvas height in pixels
themestring"light"Theme: "light" or "dark"
readonlybooleanfalseDisable editing

#4. CDN Distribution (Copy & Paste)

Embed the editor in any HTML page with just 2 lines:

#Minimal Example

<moonlight-editor width="800" height="600"></moonlight-editor> <script src="https://moonlight.mizchi.workers.dev/moonlight-editor.component.js" async></script>

#With Initial SVG (using template)

<moonlight-editor width="800" height="500"> <template> <svg viewBox="0 0 800 500"> <rect x="100" y="100" width="120" height="80" fill="#4CAF50" stroke="#2E7D32" stroke-width="2"/> <circle cx="400" cy="200" r="50" fill="#2196F3" stroke="#1565C0" stroke-width="2"/> <text x="300" y="350" font-size="24" fill="#333">Hello Moonlight!</text> </svg> </template> </moonlight-editor> <script src="https://moonlight.mizchi.workers.dev/moonlight-editor.component.js" async></script>

#With API Usage

<button onclick="alert(editor.exportSvg())">Export SVG</button> <button onclick="editor.clear()">Clear</button> <moonlight-editor id="editor" width="800" height="600" theme="dark"></moonlight-editor> <script src="https://moonlight.mizchi.workers.dev/moonlight-editor.component.js" async></script> <script> const editor = document.getElementById('editor'); // editor.exportSvg() - Get SVG string // editor.importSvg(svg) - Load SVG // editor.clear() - Clear canvas // editor.hasFocus() - Check focus state </script>

#Attributes

AttributeDefaultDescription
width400Canvas width (px)
height300Canvas height (px)
theme"light""light" or "dark"
readonly-Add to disable editing

#Self-hosting with Cloudflare Workers

pnpm build:all # Build component pnpm deploy # Deploy to Workers

The worker serves files with CORS headers, enabling cross-origin usage.

#API Reference

#Editor Handle

Both embed and web component modes return an editor handle with these methods:

interface EditorHandle { // Export current canvas as SVG string exportSvg(): string; // Import SVG string to canvas importSvg(svg: string): void; // Clear all elements clear(): void; // Check if editor has focus hasFocus(): boolean; // Register change callback onChange(callback: () => void): void; }

#Architecture

src/ ├── main.mbt # Full editor entry point ├── ui.mbt # UI components (sidebar, toolbar) ├── render.mbt # SVG rendering ├── core/ │ └── scene.mbt # Editor state management ├── model/ │ ├── types.mbt # Data models (Element, Style, etc.) │ ├── command.mbt # Undo/Redo commands │ └── element_ops.mbt # Pure element operations ├── lib/ # Shared library code ├── embed/ # Embed mode entry point ├── webcomponent/ # Web Component entry point └── preview/ # Preview mode

#Tech Stack

  • MoonBit: Systems programming language that compiles to WebAssembly/JavaScript
  • Luna: Signal-based reactive UI library for MoonBit
  • Vite: Build tool with hot module replacement
  • vite-plugin-moonbit: Vite plugin for MoonBit integration

#License

MIT

#
EmbedOptions

pub struct EmbedOptions {
width : Int
height : Int
doc_width : Int
doc_height : Int
zoom : Double
theme : String
is_readonly : Bool
initial_svg : String?
}

エディタオプション(JavaScript から渡される)

#
EmbedOptions::default

fn EmbedOptions::default() -> EmbedOptions

デフォルトオプション

#
EmbedOptions::from_js

JavaScript オブジェクトからオプションを作成

#
ImportResult

pub struct ImportResult {
elements : Array[
Element
]
is_moonlight : Bool
plain_svg_ids : Array[String]
}

インポート結果(要素と検証結果)

#
add_keyboard_listener

fn add_keyboard_listener(handler : (
Any
) -> Unit) -> Unit

window に keydown イベントリスナーを追加

#
add_keydown_listener

fn add_keydown_listener(handler : (
Any
) -> Unit) -> Unit

keydown リスナー

#
add_keyup_listener

fn add_keyup_listener(handler : (
Any
) -> Unit) -> Unit

keyup リスナー

#
add_sample_shapes

fn add_sample_shapes(state :
EditorState
, new_id : () -> String) -> Unit

サンプル図形を追加

#
app_main

fn app_main() -> Unit

アプリケーションのエントリポイント(外部から呼び出し可能)

#
clear_children_ffi

fn clear_children_ffi(el :
Any
) -> Unit

SVG コンテナの子要素をクリア

#
copy_svg_as_image

fn copy_svg_as_image(svg_str : String, width : Int, height : Int, on_done : () -> Unit, on_error : () -> Unit) -> Unit

SVGをPNG画像としてクリップボードにコピー

#
copy_text_to_clipboard

fn copy_text_to_clipboard(text : String, on_done : () -> Unit, on_error : () -> Unit) -> Unit

テキストをクリップボードにコピー

#
create_arrow_defs

fn create_arrow_defs() ->
Any

SVG 定義(マーカー)を作成

#
create_arrow_defs_node

fn create_arrow_defs_node() ->
DomNode

SVG defs を DomNode として作成

#
create_doc_shadow_filter

fn create_doc_shadow_filter(is_dark_theme : Bool) ->
Any

ドキュメントシャドウフィルタを defs に追加

#
create_doc_shadow_filter_node

fn create_doc_shadow_filter_node(is_dark_theme : Bool) ->
DomNode

ドキュメントシャドウフィルタを defs Node として作成

#
create_embedded_app

fn create_embedded_app() ->
DomNode

埋め込みモード用のアプリを作成(外部から呼び出し可能)

#
create_js_editor

fn create_js_editor(container :
Any
, options : EmbedOptions) ->
Any

JavaScript 埋め込み用エディタを作成 container: DOM コンテナ要素 options: エディタオプション 戻り値: エディタハンドル

#
create_textarea_ffi

fn create_textarea_ffi(style : String, initial_value : String, on_commit : (String) -> Unit, on_escape : (String) -> Unit, on_input : (String) -> Unit) ->
Any

テキストエリアを作成(IMEコンポジション対応)

#
debug_log

fn debug_log(msg : String) -> Unit

デバッグログ

#
download_file

fn download_file(content : String, filename : String, mime_type : String) -> Unit

ファイルをダウンロード

#
element_to_moonlight_svg

fn element_to_moonlight_svg(el :
Element
) -> String

要素を Moonlight SVG 文字列に変換(メタデータ付き)

#
element_to_svg_string

fn element_to_svg_string(el :
Element
) -> String

要素を SVG 文字列に変換(エクスポート用)

#
elements_to_moonlight_svg

fn elements_to_moonlight_svg(elements : Array[
Element
], width : Int, height : Int, bg_color : String) -> String

全要素を Moonlight SVG 形式で出力

#
elements_to_svg

fn elements_to_svg(elements : Array[
Element
], width : Int, height : Int) -> String

全要素を SVG 文字列に変換

#
get_attribute

fn get_attribute(el :
Any
, name : String) ->
Any

要素の属性を取得(存在しない場合はnull)

#
get_child_at_ffi

fn get_child_at_ffi(parent :
Any
, index : Int) ->
Any

子要素を取得

#
get_closest_data_id_ffi

fn get_closest_data_id_ffi(el :
Any
) ->
Any

要素から data-id を持つ最も近い親要素の data-id を取得

#
get_connected_points

選択要素に接続しているラインの接続ポイントを収集

#
get_current_time

fn get_current_time() -> Double

現在時刻を取得(ミリ秒)

#
get_event_target_value

fn get_event_target_value(e :
Any
) -> String

イベントから target.value を取得

#
get_svg_children

fn get_svg_children(svg :
Any
) ->
Any

SVG要素の子要素リストを取得

#
get_tag_name

fn get_tag_name(el :
Any
) -> String

要素のタグ名を取得

#
get_text_content

fn get_text_content(el :
Any
) -> String

要素のテキスト内容を取得

#
get_url_search_ffi

fn get_url_search_ffi() -> String

URL の search パラメータを取得

#
hide_popover

fn hide_popover(popover_id : String) -> Unit

Popover を非表示

#
import_svg_elements

fn import_svg_elements(svg_str : String, new_id : () -> String) -> Array[
Element
]

SVG文字列をパースして Element 配列を返す

#
import_svg_validated

fn import_svg_validated(svg_str : String, new_id : () -> String) -> ImportResult

SVG文字列をパースして検証付きでインポート

#
insert_before_ffi

fn insert_before_ffi(parent :
Any
, new_child :
Any
, ref_child :
Any
) -> Unit

指定した子要素の前に挿入

#
is_composing_ffi

fn is_composing_ffi() -> Bool

IMEコンポジション中かどうかを取得

#
is_input_focused_ffi

fn is_input_focused_ffi() -> Bool

入力要素にフォーカスがあるかチェック

#
load_from_indexeddb

fn load_from_indexeddb(db_name : String, store_name : String, key : String, on_success : (String) -> Unit, on_not_found : () -> Unit, on_error : () -> Unit) -> Unit

IndexedDB から文字列を読み込み

#
observe_resize_ffi

fn observe_resize_ffi(el :
Any
, callback : (Double, Double) -> Unit) -> Unit

ResizeObserver で要素のサイズ変更を監視

#
observe_window_resize_ffi

fn observe_window_resize_ffi(callback : (Int, Int) -> Unit) -> Unit

ウィンドウサイズを監視

#
open_file_dialog

fn open_file_dialog(accept : String, on_file : (String) -> Unit) -> Unit

ファイル選択ダイアログを開く

#
parse_double

fn parse_double(s : String) -> Double?

文字列を Double にパース

#
parse_svg_string

fn parse_svg_string(svg_str : String) ->
Any

SVG文字列をパースしてDOM要素を返す

#
read_clipboard_text

fn read_clipboard_text(on_success : (String) -> Unit, on_error : () -> Unit) -> Unit

クリップボードからテキストを読み込み

#
remove_child_safe_ffi

fn remove_child_safe_ffi(parent :
Any
, child :
Any
) -> Unit

子要素を安全に削除(存在しない場合は何もしない)

#
remove_element_ffi

fn remove_element_ffi(el :
Any
) -> Unit

要素を親から削除

#
render_all_anchor_points

fn render_all_anchor_points(elements : Array[
Element
], exclude_id : String) -> Array[
Any
]

全てのアンカーポイントを描画(ドラッグ中の可視化用)

#
render_all_anchors_vnode

全アンカーポイントを VNode として描画(ライン編集中、Effect ベース)

#
render_box_select_vnode

矩形選択ボックスを VNode として描画(Effect ベース)

#
render_canvas_background

fn render_canvas_background(doc_width : Double, doc_height : Double, viewport :
Viewport
, screen_width : Int, screen_height : Int, is_dark_theme : Bool) -> Array[
Any
]

Canvas 背景レイヤーを描画
  • 境界外のグレー背景(テーマ連動)
  • ドキュメント境界(白/黒 rect + ドロップシャドウ)

#
render_canvas_background_vnode

Canvas 背景を VNode として描画(Effect ベース)
  • 境界外のグレー背景(テーマ連動)
  • ドキュメント境界(白/黒 rect + ドロップシャドウ)

#
render_connected_anchor_highlights

fn render_connected_anchor_highlights(elements : Array[
Element
], selected_el :
Element
) -> Array[
Any
]

選択要素に接続しているラインのアンカーポイントを黄色でハイライト描画

#
render_connected_point

fn render_connected_point(point :
Point
) ->
Any

接続済みポイントを描画(選択要素に接続しているラインの端点)

#
render_connection_highlight

fn render_connection_highlight(point :
Point
) ->
Any

接続ポイントのハイライトを描画(スナップ先)

#
render_connection_highlight_node

fn render_connection_highlight_node(point :
Point
) ->
DomNode

接続ポイントハイライトを DomNode として作成

#
render_connection_highlight_vnode

接続ハイライトを VNode として描画(Effect ベース)

#
render_context_menu

コンテキストメニューをレンダリング

#
render_delete_button

fn render_delete_button(state :
EditorState
, history :
History
, element_id : String, close_menu : () -> Unit) ->
DomNode

削除ボタンコンポーネント

#
render_editor_modal

エディタモーダルをレンダリング(フルエディタを別ウィンドウ風に表示)

#
render_element

要素を SVG ノードに変換 parent: テキスト子要素の場合、親要素を渡すと中央に配置

#
render_element_anchors

選択中の図形のアンカーポイントを描画(ドラッグでライン生成用)

#
render_element_anchors_nodes

アンカーポイントを DomNode として作成

#
render_element_node

要素を DomNode に変換(Luna VNode ベース)

#
render_element_vnode

要素を VNode として描画(Effect で直接 DOM 更新)

#
render_floating_panel

フローティングパネルをレンダリング

#
render_grid

fn render_grid(width : Int, height : Int, grid_size : Int, viewport :
Viewport
) -> Array[
Any
]

グリッド線を描画

#
render_grid_nodes

fn render_grid_nodes(width : Int, height : Int, grid_size : Int, viewport :
Viewport
) -> Array[
DomNode
]

グリッド線を DomNode として作成

#
render_grid_vnode

グリッド線を VNode として描画

#
render_inline_panel

インラインパネルをレンダリング(モーダル内で使用、position: fixed なし)

#
render_layer_operations

fn render_layer_operations(state :
EditorState
, history :
History
, element_id : String, close_menu : () -> Unit) ->
DomNode

Layer 操作コンポーネント

#
render_resize_handles

リサイズハンドルを描画

#
render_resize_handles_nodes

リサイズハンドルを DomNode として作成

#
render_selection_box

矩形選択ボックスを描画

#
render_selection_box_node

矩形選択ボックスを DomNode として作成

#
render_selection_overlay

選択オーバーレイ(ハンドル・アンカー)を描画(Effect ベース)

#
render_sidebar

サイドバーをレンダリング(後方互換性のため残す)

#
render_style_editor

fn render_style_editor(state :
EditorState
, history :
History
, element_id : String, close_menu : () -> Unit) ->
DomNode

共通スタイルエディタコンポーネント(コンテキストメニュー・詳細パネル両方で使用) close_menu: メニューを閉じるコールバック(コンテキストメニュー用、詳細パネルでは空の関数を渡す) ShapeConfig を使用して、シェープごとに表示する項目を制御

#
render_svg_container

fn render_svg_container(width : Int, height : Int, on_pointerdown : (
Any
) -> Unit, on_pointermove : (
Any
) -> Unit, on_pointerup : (
Any
) -> Unit, on_contextmenu : (
Any
) -> Unit, children : Array[
Any
]) ->
Any

SVG コンテナを作成(イベントハンドラ付き)

#
render_svg_container_with_viewport

fn render_svg_container_with_viewport(width : Int, height : Int, viewport :
Viewport
, on_pointerdown : (
Any
) -> Unit, on_pointermove : (
Any
) -> Unit, on_pointerup : (
Any
) -> Unit, on_contextmenu : (
Any
) -> Unit, on_wheel : (
Any
) -> Unit, children : Array[
Any
]) ->
Any

SVG コンテナを作成(ビューポート対応)

#
render_text_input

fn render_text_input(state :
EditorState
, commit : (String) -> Unit, cancel : () -> Unit) ->
DomNode

テキスト入力オーバーレイをレンダリング

#
save_to_indexeddb

fn save_to_indexeddb(db_name : String, store_name : String, key : String, value : String, on_done : () -> Unit, on_error : () -> Unit) -> Unit

IndexedDB に文字列を保存

#
schedule_focus_with_value_ffi

fn schedule_focus_with_value_ffi(selector : String, value : String) -> Int

指定セレクタの要素にフォーカスを遅延設定(初期値付き)

#
schedule_textarea_focus_ffi

fn schedule_textarea_focus_ffi(selector : String, value : String) -> Int

指定セレクタのテキストエリアにフォーカスを遅延設定(IMEコンポジション対応)

#
set_multiline_text_ffi

fn set_multiline_text_ffi(text_el :
Any
, content : String, x : Double, font_size : Double) -> Unit

SVG text 要素に複数行テキストを設定(tspan使用)

#
show_popover_at_button

fn show_popover_at_button(popover_id : String, e :
Any
) -> Unit

Popover を表示してボタン位置に配置

#
svg_to_dom_node

SVG を DomNode にラップ

#
update_svg_background_ffi

fn update_svg_background_ffi(svg :
Any
, background : String) -> Unit

SVG の背景スタイルを更新

#
update_svg_css_vars_ffi

fn update_svg_css_vars_ffi(svg :
Any
, stroke : String, fill : String, text_color : String) -> Unit

SVG の CSS 変数を更新

#
update_viewbox_ffi

fn update_viewbox_ffi(svg :
Any
, viewbox : String) -> Unit

SVG の viewBox を更新