├── Gemfile ├── images ├── front-end.jpg └── preset-editor.jpg ├── lib ├── jekyll │ ├── svg_viewer │ │ ├── version.rb │ │ ├── config.rb │ │ ├── preview_page.rb │ │ ├── asset_manager.rb │ │ └── tag.rb │ └── svg_viewer.rb └── jekyll-svg-viewer.rb ├── example_site ├── Gemfile ├── _config.yml ├── index.md └── _layouts │ └── default.html ├── scripts └── build-example.sh ├── LICENSE.txt ├── jekyll-svg-viewer.gemspec ├── Rakefile ├── assets └── svg-viewer │ ├── i18n │ └── locales.json │ ├── preview │ ├── index.html │ ├── preset-builder.css │ └── preset-builder.js │ ├── css │ └── svg-viewer.css │ └── js │ └── svg-viewer.js ├── Gemfile.lock └── README.md /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gemspec 4 | 5 | -------------------------------------------------------------------------------- /images/front-end.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ttscoff/jekyll-svg-viewer/main/images/front-end.jpg -------------------------------------------------------------------------------- /images/preset-editor.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ttscoff/jekyll-svg-viewer/main/images/preset-editor.jpg -------------------------------------------------------------------------------- /lib/jekyll/svg_viewer/version.rb: -------------------------------------------------------------------------------- 1 | module Jekyll 2 | module SvgViewer 3 | VERSION = "0.1.0" 4 | end 5 | end 6 | 7 | -------------------------------------------------------------------------------- /example_site/Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gem "jekyll", "~> 4.3" 4 | gem "jekyll-svg-viewer", path: ".." 5 | 6 | -------------------------------------------------------------------------------- /lib/jekyll/svg_viewer.rb: -------------------------------------------------------------------------------- 1 | require_relative "svg_viewer/version" 2 | require_relative "svg_viewer/config" 3 | require_relative "svg_viewer/tag" 4 | require_relative "svg_viewer/asset_manager" 5 | require_relative "svg_viewer/preview_page" 6 | 7 | -------------------------------------------------------------------------------- /lib/jekyll-svg-viewer.rb: -------------------------------------------------------------------------------- 1 | require "jekyll" 2 | 3 | require_relative "jekyll/svg_viewer" 4 | 5 | Jekyll::Hooks.register :site, :after_init do |site| 6 | Jekyll::SvgViewer::AssetManager.register(site) 7 | Jekyll::SvgViewer::PreviewPage.register(site) 8 | end 9 | 10 | Liquid::Template.register_tag("svg_viewer", Jekyll::SvgViewer::Tag) 11 | 12 | -------------------------------------------------------------------------------- /scripts/build-example.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | 4 | ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" 5 | EXAMPLE_DIR="${ROOT}/example_site" 6 | 7 | if ! command -v bundle >/dev/null 2>&1; then 8 | echo "Bundler is required to build the example site." >&2 9 | exit 1 10 | fi 11 | 12 | pushd "${EXAMPLE_DIR}" >/dev/null 13 | bundle install --quiet 14 | bundle exec jekyll build --trace 15 | popd >/dev/null 16 | 17 | -------------------------------------------------------------------------------- /example_site/_config.yml: -------------------------------------------------------------------------------- 1 | title: SVG Viewer Demo 2 | description: Minimal site demonstrating the jekyll-svg-viewer plugin. 3 | baseurl: "" 4 | url: "http://localhost:4000" 5 | 6 | plugins: 7 | - jekyll-svg-viewer 8 | 9 | svg_viewer: 10 | defaults: 11 | height: "65vh" 12 | zoom: "120" 13 | min_zoom: "25" 14 | max_zoom: "400" 15 | zoom_step: "10" 16 | controls_position: "bottom" 17 | controls_buttons: "both,aligncenter,zoom_in,zoom_out,reset,center" 18 | 19 | exclude: 20 | - Gemfile 21 | - Gemfile.lock 22 | 23 | -------------------------------------------------------------------------------- /example_site/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: default 3 | title: Interactive Floorplan 4 | --- 5 | 6 | {% assign svg_path = "https://upload.wikimedia.org/wikipedia/commons/5/5c/Simple_world_map.svg" %} 7 | 8 |
9 | Embed SVG diagrams with zoom, pan, and keyboard controls using the
10 | {% raw %}{% svg_viewer %}{% endraw %} Liquid tag supplied by this gem.
11 |
23 | Configure defaults globally in _config.yml or craft per-instance overrides.
24 | Visit /svg-viewer/preset-builder/ to generate Liquid tags and YAML snippets.
25 |
\S+)?'/) do
38 | m = Regexp.last_match
39 | major = m['major'].to_i
40 | minor = m['minor'].to_i
41 | inc = m['inc'].to_i
42 | pre = m['pre']
43 |
44 | case args[:type]
45 | when /^maj/
46 | major += 1
47 | minor = 0
48 | inc = 0
49 | when /^min/
50 | minor += 1
51 | inc = 0
52 | else
53 | inc += 1
54 | end
55 |
56 | $stdout.puts "At version #{major}.#{minor}.#{inc}#{pre}"
57 | "VERSION = '#{major}.#{minor}.#{inc}#{pre}'"
58 | end
59 | File.open(version_file, 'w+') { |f| f.puts content }
60 | end
61 |
62 | task default: %i[clobber package]
63 |
--------------------------------------------------------------------------------
/example_site/_layouts/default.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | {{ page.title }} · {{ site.title }}
7 |
8 |
43 |
44 |
45 |
46 | {{ site.title }}
47 | {{ site.description }}
48 |
49 |
50 | {{ content }}
51 |
52 |
53 |
54 |
55 |
--------------------------------------------------------------------------------
/assets/svg-viewer/i18n/locales.json:
--------------------------------------------------------------------------------
1 | {
2 | "en": {
3 | "zoom_in": "Zoom In",
4 | "zoom_in_title": "Zoom In (Ctrl +)",
5 | "zoom_out": "Zoom Out",
6 | "zoom_out_title": "Zoom Out (Ctrl -)",
7 | "reset_zoom": "Reset Zoom",
8 | "center_view": "Center View",
9 | "copy_center": "Copy Center",
10 | "copy_center_title": "Copy current center coordinates",
11 | "instruction_click": "Cmd/Ctrl-click to zoom in, Option/Alt-click to zoom out.",
12 | "instruction_scroll": "Scroll up to zoom in, scroll down to zoom out.",
13 | "instruction_drag_scroll": "Drag to pan around the image while scrolling zooms.",
14 | "instruction_drag": "Drag to pan around the image.",
15 | "zoom_slider_label": "Zoom level"
16 | },
17 | "de": {
18 | "zoom_in": "Vergrößern",
19 | "zoom_in_title": "Vergrößern (Strg +)",
20 | "zoom_out": "Verkleinern",
21 | "zoom_out_title": "Verkleinern (Strg -)",
22 | "reset_zoom": "Zoom zurücksetzen",
23 | "center_view": "Ansicht zentrieren",
24 | "copy_center": "Zentrum kopieren",
25 | "copy_center_title": "Aktuelle Mittelpunktkoordinaten kopieren",
26 | "instruction_click": "Mit Cmd/Strg-Klick vergrößern, mit Wahltaste/Alt-Klick verkleinern.",
27 | "instruction_scroll": "Nach oben scrollen zum Vergrößern, nach unten scrollen zum Verkleinern.",
28 | "instruction_drag_scroll": "Ziehen zum Schwenken, während scrollen zoomt.",
29 | "instruction_drag": "Ziehen zum Schwenken des Bildes.",
30 | "zoom_slider_label": "Zoomstufe"
31 | },
32 | "es": {
33 | "zoom_in": "Acercar",
34 | "zoom_in_title": "Acercar (Ctrl +)",
35 | "zoom_out": "Alejar",
36 | "zoom_out_title": "Alejar (Ctrl -)",
37 | "reset_zoom": "Restablecer zoom",
38 | "center_view": "Centrar vista",
39 | "copy_center": "Copiar centro",
40 | "copy_center_title": "Copiar las coordenadas del centro actual",
41 | "instruction_click": "Cmd/Ctrl-clic para acercar, Opción/Alt-clic para alejar.",
42 | "instruction_scroll": "Desplaza hacia arriba para acercar, hacia abajo para alejar.",
43 | "instruction_drag_scroll": "Arrastra para desplazarte por la imagen mientras el desplazamiento hace zoom.",
44 | "instruction_drag": "Arrastra para desplazarte por la imagen.",
45 | "zoom_slider_label": "Nivel de zoom"
46 | },
47 | "fr": {
48 | "zoom_in": "Zoom avant",
49 | "zoom_in_title": "Zoom avant (Ctrl +)",
50 | "zoom_out": "Zoom arrière",
51 | "zoom_out_title": "Zoom arrière (Ctrl -)",
52 | "reset_zoom": "Réinitialiser le zoom",
53 | "center_view": "Centrer la vue",
54 | "copy_center": "Copier le centre",
55 | "copy_center_title": "Copier les coordonnées du centre actuel",
56 | "instruction_click": "Cliquer avec Cmd/Ctrl pour zoomer, cliquer avec Option/Alt pour dézoomer.",
57 | "instruction_scroll": "Faites défiler vers le haut pour zoomer, vers le bas pour dézoomer.",
58 | "instruction_drag_scroll": "Faites glisser pour vous déplacer dans l’image pendant que le défilement effectue un zoom.",
59 | "instruction_drag": "Faites glisser pour vous déplacer dans l’image.",
60 | "zoom_slider_label": "Niveau de zoom"
61 | },
62 | "it": {
63 | "zoom_in": "Zoom avanti",
64 | "zoom_in_title": "Zoom avanti (Ctrl +)",
65 | "zoom_out": "Zoom indietro",
66 | "zoom_out_title": "Zoom indietro (Ctrl -)",
67 | "reset_zoom": "Reimposta zoom",
68 | "center_view": "Centra vista",
69 | "copy_center": "Copia centro",
70 | "copy_center_title": "Copia le coordinate del centro attuale",
71 | "instruction_click": "Cmd/Ctrl-clic per ingrandire, Option/Alt-clic per ridurre.",
72 | "instruction_scroll": "Scorri verso l’alto per ingrandire, verso il basso per ridurre.",
73 | "instruction_drag_scroll": "Trascina per spostarti nell’immagine mentre lo scorrimento esegue lo zoom.",
74 | "instruction_drag": "Trascina per spostarti nell’immagine.",
75 | "zoom_slider_label": "Livello di zoom"
76 | }
77 | }
78 |
79 |
--------------------------------------------------------------------------------
/lib/jekyll/svg_viewer/asset_manager.rb:
--------------------------------------------------------------------------------
1 | require "fileutils"
2 | require "json"
3 | require "set"
4 |
5 | module Jekyll
6 | module SvgViewer
7 | class AssetManager
8 | ASSET_ROOT = File.expand_path("../../../assets/svg-viewer", __dir__)
9 | OUTPUT_DIR = "assets/svg-viewer"
10 |
11 | class << self
12 | def hooks_registered?
13 | @hooks_registered ||= false
14 | end
15 |
16 | def register(_site)
17 | return if hooks_registered?
18 |
19 | @hooks_registered = true
20 |
21 | Jekyll::Hooks.register [:pages, :documents], :post_render do |doc|
22 | ensure_asset_tags(doc)
23 | end
24 |
25 | Jekyll::Hooks.register :site, :post_write do |s|
26 | copy_assets(s)
27 | end
28 | end
29 |
30 | def flag_page_for_assets(site, page)
31 | runtime = site.config["svg_viewer_runtime"] ||= {}
32 | runtime["required_paths"] ||= Set.new
33 |
34 | mark_page_hash!(page)
35 | path = extract_path(page)
36 | runtime["required_paths"] << path if path
37 | end
38 |
39 | def copy_assets(site)
40 | destination = File.join(site.dest, OUTPUT_DIR)
41 | FileUtils.mkdir_p(destination)
42 | Dir.glob(File.join(ASSET_ROOT, "**", "*")).each do |path|
43 | next if File.directory?(path)
44 |
45 | relative = path.delete_prefix("#{ASSET_ROOT}/")
46 | target = File.join(destination, relative)
47 | FileUtils.mkdir_p(File.dirname(target))
48 | FileUtils.cp(path, target)
49 | end
50 | end
51 |
52 | def locale_payload(_site)
53 | locales_path = File.join(ASSET_ROOT, "i18n", "locales.json")
54 | return {} unless File.exist?(locales_path)
55 |
56 | JSON.parse(File.read(locales_path))
57 | rescue JSON::ParserError
58 | {}
59 | end
60 |
61 | private
62 |
63 | def ensure_asset_tags(doc)
64 | return unless requires_assets?(doc)
65 | return unless doc.respond_to?(:output) && doc.output
66 | return unless html_output?(doc)
67 |
68 | baseurl = doc.site.config["baseurl"].to_s.chomp("/")
69 | css_href = "#{baseurl}/#{OUTPUT_DIR}/css/svg-viewer.css"
70 | js_src = "#{baseurl}/#{OUTPUT_DIR}/js/svg-viewer.js"
71 |
72 | unless doc.output.include?(css_href)
73 | replaced = doc.output.sub(/]*>/i) do |match|
74 | "#{match}\n"
75 | end
76 | doc.output = replaced unless replaced.nil?
77 | doc.output =
78 | "\n#{doc.output}" if replaced.nil?
79 | end
80 |
81 | unless doc.output.include?(js_src)
82 | replaced = doc.output.sub(/<\/head>/i) do |match|
83 | "\n#{match}"
84 | end
85 | doc.output = replaced unless replaced.nil?
86 | doc.output =
87 | "#{doc.output}\n" if replaced.nil?
88 | end
89 | end
90 |
91 | def requires_assets?(doc)
92 | return true if doc.respond_to?(:data) && doc.data["svg_viewer_required"]
93 |
94 | runtime = doc.site.config["svg_viewer_runtime"] || {}
95 | required_paths = runtime["required_paths"]
96 | return false unless required_paths
97 |
98 | path = extract_path(doc)
99 | required_paths.include?(path)
100 | end
101 |
102 | def mark_page_hash!(page)
103 | if page.is_a?(Hash)
104 | page["svg_viewer_required"] = true
105 | elsif page.respond_to?(:data)
106 | page.data["svg_viewer_required"] = true
107 | end
108 | end
109 |
110 | def extract_path(page)
111 | if page.respond_to?(:relative_path)
112 | page.relative_path
113 | elsif page.respond_to?(:path)
114 | page.path
115 | elsif page.is_a?(Hash)
116 | page["path"] || page["page_path"]
117 | else
118 | nil
119 | end
120 | end
121 |
122 | def html_output?(doc)
123 | ext = doc.respond_to?(:output_ext) ? doc.output_ext : File.extname(doc.path)
124 | %w[.html .htm].include?(ext.downcase)
125 | end
126 | end
127 | end
128 | end
129 | end
130 |
--------------------------------------------------------------------------------
/Gemfile.lock:
--------------------------------------------------------------------------------
1 | PATH
2 | remote: .
3 | specs:
4 | jekyll-svg-viewer (0.1.0)
5 | jekyll (>= 4.0, < 5.0)
6 | liquid (>= 4.0)
7 |
8 | GEM
9 | remote: https://rubygems.org/
10 | specs:
11 | addressable (2.8.7)
12 | public_suffix (>= 2.0.2, < 7.0)
13 | base64 (0.3.0)
14 | bigdecimal (3.3.1)
15 | colorator (1.1.0)
16 | concurrent-ruby (1.3.5)
17 | csv (3.3.5)
18 | em-websocket (0.5.3)
19 | eventmachine (>= 0.12.9)
20 | http_parser.rb (~> 0)
21 | eventmachine (1.2.7)
22 | ffi (1.17.2)
23 | ffi (1.17.2-aarch64-linux-gnu)
24 | ffi (1.17.2-aarch64-linux-musl)
25 | ffi (1.17.2-arm-linux-gnu)
26 | ffi (1.17.2-arm-linux-musl)
27 | ffi (1.17.2-arm64-darwin)
28 | ffi (1.17.2-x86-linux-gnu)
29 | ffi (1.17.2-x86-linux-musl)
30 | ffi (1.17.2-x86_64-darwin)
31 | ffi (1.17.2-x86_64-linux-gnu)
32 | ffi (1.17.2-x86_64-linux-musl)
33 | forwardable-extended (2.6.0)
34 | google-protobuf (4.33.0)
35 | bigdecimal
36 | rake (>= 13)
37 | google-protobuf (4.33.0-aarch64-linux-gnu)
38 | bigdecimal
39 | rake (>= 13)
40 | google-protobuf (4.33.0-aarch64-linux-musl)
41 | bigdecimal
42 | rake (>= 13)
43 | google-protobuf (4.33.0-arm64-darwin)
44 | bigdecimal
45 | rake (>= 13)
46 | google-protobuf (4.33.0-x86-linux-gnu)
47 | bigdecimal
48 | rake (>= 13)
49 | google-protobuf (4.33.0-x86-linux-musl)
50 | bigdecimal
51 | rake (>= 13)
52 | google-protobuf (4.33.0-x86_64-darwin)
53 | bigdecimal
54 | rake (>= 13)
55 | google-protobuf (4.33.0-x86_64-linux-gnu)
56 | bigdecimal
57 | rake (>= 13)
58 | google-protobuf (4.33.0-x86_64-linux-musl)
59 | bigdecimal
60 | rake (>= 13)
61 | http_parser.rb (0.8.0)
62 | i18n (1.14.7)
63 | concurrent-ruby (~> 1.0)
64 | jekyll (4.4.1)
65 | addressable (~> 2.4)
66 | base64 (~> 0.2)
67 | colorator (~> 1.0)
68 | csv (~> 3.0)
69 | em-websocket (~> 0.5)
70 | i18n (~> 1.0)
71 | jekyll-sass-converter (>= 2.0, < 4.0)
72 | jekyll-watch (~> 2.0)
73 | json (~> 2.6)
74 | kramdown (~> 2.3, >= 2.3.1)
75 | kramdown-parser-gfm (~> 1.0)
76 | liquid (~> 4.0)
77 | mercenary (~> 0.3, >= 0.3.6)
78 | pathutil (~> 0.9)
79 | rouge (>= 3.0, < 5.0)
80 | safe_yaml (~> 1.0)
81 | terminal-table (>= 1.8, < 4.0)
82 | webrick (~> 1.7)
83 | jekyll-sass-converter (3.1.0)
84 | sass-embedded (~> 1.75)
85 | jekyll-watch (2.2.1)
86 | listen (~> 3.0)
87 | json (2.16.0)
88 | kramdown (2.5.1)
89 | rexml (>= 3.3.9)
90 | kramdown-parser-gfm (1.1.0)
91 | kramdown (~> 2.0)
92 | liquid (4.0.4)
93 | listen (3.9.0)
94 | rb-fsevent (~> 0.10, >= 0.10.3)
95 | rb-inotify (~> 0.9, >= 0.9.10)
96 | mercenary (0.4.0)
97 | pathutil (0.16.2)
98 | forwardable-extended (~> 2.6)
99 | public_suffix (6.0.2)
100 | rake (13.3.1)
101 | rb-fsevent (0.11.2)
102 | rb-inotify (0.11.1)
103 | ffi (~> 1.0)
104 | rexml (3.4.4)
105 | rouge (4.6.1)
106 | safe_yaml (1.0.5)
107 | sass-embedded (1.93.3)
108 | google-protobuf (~> 4.31)
109 | rake (>= 13)
110 | sass-embedded (1.93.3-aarch64-linux-android)
111 | google-protobuf (~> 4.31)
112 | sass-embedded (1.93.3-aarch64-linux-gnu)
113 | google-protobuf (~> 4.31)
114 | sass-embedded (1.93.3-aarch64-linux-musl)
115 | google-protobuf (~> 4.31)
116 | sass-embedded (1.93.3-arm-linux-androideabi)
117 | google-protobuf (~> 4.31)
118 | sass-embedded (1.93.3-arm-linux-gnueabihf)
119 | google-protobuf (~> 4.31)
120 | sass-embedded (1.93.3-arm-linux-musleabihf)
121 | google-protobuf (~> 4.31)
122 | sass-embedded (1.93.3-arm64-darwin)
123 | google-protobuf (~> 4.31)
124 | sass-embedded (1.93.3-riscv64-linux-android)
125 | google-protobuf (~> 4.31)
126 | sass-embedded (1.93.3-riscv64-linux-gnu)
127 | google-protobuf (~> 4.31)
128 | sass-embedded (1.93.3-riscv64-linux-musl)
129 | google-protobuf (~> 4.31)
130 | sass-embedded (1.93.3-x86_64-darwin)
131 | google-protobuf (~> 4.31)
132 | sass-embedded (1.93.3-x86_64-linux-android)
133 | google-protobuf (~> 4.31)
134 | sass-embedded (1.93.3-x86_64-linux-gnu)
135 | google-protobuf (~> 4.31)
136 | sass-embedded (1.93.3-x86_64-linux-musl)
137 | google-protobuf (~> 4.31)
138 | terminal-table (3.0.2)
139 | unicode-display_width (>= 1.1.1, < 3)
140 | unicode-display_width (2.6.0)
141 | webrick (1.9.1)
142 |
143 | PLATFORMS
144 | aarch64-linux-android
145 | aarch64-linux-gnu
146 | aarch64-linux-musl
147 | arm-linux-androideabi
148 | arm-linux-gnu
149 | arm-linux-gnueabihf
150 | arm-linux-musl
151 | arm-linux-musleabihf
152 | arm64-darwin
153 | riscv64-linux-android
154 | riscv64-linux-gnu
155 | riscv64-linux-musl
156 | ruby
157 | x86-linux-gnu
158 | x86-linux-musl
159 | x86_64-darwin
160 | x86_64-linux-android
161 | x86_64-linux-gnu
162 | x86_64-linux-musl
163 |
164 | DEPENDENCIES
165 | jekyll-svg-viewer!
166 |
167 | BUNDLED WITH
168 | 2.7.2
169 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # jekyll-svg-viewer
2 |
3 | `jekyll-svg-viewer` brings the **WP SVG Viewer** frontend to static Jekyll sites. It ships a Liquid tag for rendering interactive SVG diagrams, automatically injects the required JavaScript/CSS, mirrors the shortcode options from the WordPress plugin, and includes a standalone preset builder page for crafting configurations. Source code lives at [github.com/ttscoff/jekyll-svg-viewer](https://github.com/ttscoff/jekyll-svg-viewer).
4 |
5 | ## Installation
6 |
7 | 1. Add the published gem to your Jekyll site:
8 |
9 | ```ruby
10 | # Gemfile
11 | gem "jekyll-svg-viewer"
12 | ```
13 |
14 | > Need the latest unreleased changes? Point Bundler at GitHub instead:
15 | >
16 | > ```ruby
17 | > gem "jekyll-svg-viewer", github: "ttscoff/jekyll-svg-viewer"
18 | > ```
19 |
20 | 2. Enable the plugin:
21 |
22 | ```yaml
23 | # _config.yml
24 | plugins:
25 | - jekyll-svg-viewer
26 | ```
27 |
28 | 3. Run `bundle install`, then build or serve your site as usual (`bundle exec jekyll build` / `bundle exec jekyll serve`). The viewer assets are copied into `_site/assets/svg-viewer/` as part of the build.
29 |
30 | ## Liquid Tag Usage
31 |
32 | Place the `svg_viewer` tag anywhere Liquid tags are supported:
33 |
34 | ```liquid
35 | {% svg_viewer src="/assets/maps/campus.svg" height="75vh" controls_buttons="compact,alignright,zoom_in,zoom_out,center" zoom="140" %}
36 | ```
37 |
38 | All shortcode options from the WordPress version are supported. Common attributes include:
39 |
40 | | Attribute | Purpose |
41 | | --------------------------------------------------- | --------------------------------------------------------------------------------------- |
42 | | `src` | **Required.** Absolute URL or site-root relative path to the SVG. |
43 | | `height` | Viewer height (e.g. `600px`, `80vh`). |
44 | | `zoom`, `min_zoom`, `max_zoom`, `zoom_step` | Initial zoom, min/max bounds, and button increment (percentages). |
45 | | `center_x`, `center_y` | Lock the default viewport to explicit SVG coordinates. |
46 | | `show_coords` | `true` enables the coordinate helper button. |
47 | | `controls_position` | `top`, `bottom`, `left`, or `right`. |
48 | | `controls_buttons` | Comma-delimited layout keywords and button names (e.g. `both,alignleft,zoom_in,reset`). |
49 | | `button_fill`, `button_border`, `button_foreground` | Hex colors mapped to CSS custom properties for the buttons. |
50 | | `pan_mode`, `zoom_mode` | Advanced gesture overrides (`scroll`, `drag`, `click`). |
51 | | `title`, `caption` | Optional markup rendered above/below the viewer wrapper. |
52 |
53 | Any attribute omitted from the tag falls back to `_config.yml` defaults (see below).
54 |
55 | ## Site-wide Defaults
56 |
57 | Configure default values in `_config.yml` under `svg_viewer.defaults`. The builder’s YAML modal emits the same structure.
58 |
59 | ```yaml
60 | svg_viewer:
61 | defaults:
62 | height: "600px"
63 | zoom: "100"
64 | min_zoom: "25"
65 | max_zoom: "800"
66 | zoom_step: "10"
67 | controls_position: "top"
68 | controls_buttons: "both"
69 | pan_mode: ""
70 | zoom_mode: ""
71 | show_coords: false
72 | ```
73 |
74 | Only fields you override are required. `src`, `center_x`, and `center_y` remain per-tag settings.
75 |
76 | ## Screenshots
77 |
78 | 
79 |
80 | 
81 |
82 | ## Localized Assets & Automatic Injection
83 |
84 | The gem copies the viewer script, stylesheet, and localization JSON into `_site/assets/svg-viewer/`. Whenever a page renders `{% svg_viewer %}`, the plugin:
85 |
86 | - Marks the document so the CSS/JS tags are injected into `` once during `post_render`.
87 | - Ensures the asset files exist by the `post_write` phase.
88 |
89 | You don’t need to manually edit layouts or partials—the assets appear only on pages that use the tag.
90 |
91 | ## Preset Builder Page
92 |
93 | The plugin also generates a preset editor at `/svg-viewer/preset-builder/`. It mirrors the WordPress admin experience:
94 |
95 | - Enter an SVG path, tweak zoom/controls/colors, and load a live preview.
96 | - Capture the current viewport to populate `center_x` / `center_y`.
97 | - Copy a ready-to-use Liquid tag.
98 | - Generate a `_config.yml` snippet (excluding `src` and the captured center) inside a modal dialog.
99 |
100 | This is the quickest way to dial in viewer options without memorizing every attribute.
101 |
102 | ## Development Notes
103 |
104 | - The gemspec bundles the compiled frontend assets. If you update `wp-svg-viewer/js/svg-viewer.js` or related CSS, re-copy them into `jekyll-svg-viewer/assets/svg-viewer/`.
105 | - The preset builder JavaScript (`assets/svg-viewer/preview/preset-builder.js`) is framework-free and mirrors the PHP logic used by the WordPress plugin.
106 | - A minimal smoke-test site is available in `example_site/`. Run `scripts/build-example.sh` to install dependencies and build it locally.
107 |
108 | ## License
109 |
110 | MIT © Brett Terpstra
111 |
--------------------------------------------------------------------------------
/assets/svg-viewer/preview/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | SVG Viewer Preset Builder
7 |
8 |
9 |
10 |
11 |
12 |
13 |
17 |
18 |
19 |
20 | SVG Viewer Preset Builder
21 | Adjust the options, load the preview, then copy the Liquid tag or configuration YAML for your Jekyll site.
22 |
23 |
24 |
25 |
132 |
133 |
134 |
135 |
136 |
137 |
138 |
139 |
140 |
141 |
142 |
143 |
144 |
145 |
146 |
147 |
148 | Live Preview
149 | Provide an SVG source and press “Load Preview” to render the interactive viewer.
150 |
151 |
152 |
153 |
154 |
155 |
156 |
157 |
158 |
159 |
160 |
173 |
174 |
175 |
176 |
177 |
178 |
--------------------------------------------------------------------------------
/assets/svg-viewer/preview/preset-builder.css:
--------------------------------------------------------------------------------
1 | .preset-builder-body {
2 | margin: 0;
3 | min-height: 100vh;
4 | font-family: "Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
5 | sans-serif;
6 | color: #e8ecf6;
7 | background: radial-gradient(
8 | circle at top,
9 | rgba(79, 70, 229, 0.35),
10 | transparent 55%
11 | ),
12 | radial-gradient(circle at bottom, rgba(16, 185, 129, 0.2), transparent 45%),
13 | #090b15;
14 | padding: 3rem clamp(1.25rem, 2vw, 2.5rem);
15 | box-sizing: border-box;
16 | }
17 |
18 | .preset-builder {
19 | max-width: 1400px;
20 | margin: 0 auto;
21 | display: grid;
22 | grid-template-columns: minmax(320px, 1fr) minmax(360px, 1.1fr);
23 | gap: clamp(2rem, 3vw, 3rem);
24 | align-items: start;
25 | }
26 |
27 | .preset-builder__panel,
28 | .preset-builder__preview .preview-card {
29 | background: rgba(8, 11, 23, 0.85);
30 | backdrop-filter: blur(24px);
31 | border: 1px solid rgba(148, 163, 184, 0.15);
32 | border-radius: 20px;
33 | box-shadow: 0 30px 60px -25px rgba(15, 23, 42, 0.6);
34 | padding: clamp(1.75rem, 2.4vw, 2.5rem);
35 | }
36 |
37 | .preset-builder__hero h1 {
38 | margin: 0;
39 | font-size: clamp(1.8rem, 3vw, 2.4rem);
40 | font-weight: 700;
41 | letter-spacing: -0.02em;
42 | }
43 |
44 | .preset-builder__hero p {
45 | margin: 0.75rem 0 0;
46 | color: rgba(226, 232, 240, 0.7);
47 | line-height: 1.6;
48 | }
49 |
50 | .preset-builder__form {
51 | margin-top: 2rem;
52 | display: flex;
53 | flex-direction: column;
54 | gap: 1.25rem;
55 | }
56 |
57 | .field {
58 | display: flex;
59 | flex-direction: column;
60 | gap: 0.5rem;
61 | }
62 |
63 | .field.full {
64 | grid-column: 1 / -1;
65 | }
66 |
67 | .field label {
68 | font-size: 0.875rem;
69 | text-transform: uppercase;
70 | letter-spacing: 0.08em;
71 | color: rgba(148, 163, 184, 0.8);
72 | }
73 |
74 | .field input[type="text"],
75 | .field input[type="number"],
76 | .field input[type="color"],
77 | .field textarea,
78 | .field select {
79 | background: rgba(17, 24, 39, 0.7);
80 | border: 1px solid rgba(148, 163, 184, 0.2);
81 | border-radius: 12px;
82 | padding: 0.75rem 0.9rem;
83 | color: #e8ecf6;
84 | font-size: 1rem;
85 | transition: border-color 0.2s ease, box-shadow 0.2s ease;
86 | }
87 |
88 | .field input[type="color"] {
89 | padding: 0.35rem;
90 | height: 2.75rem;
91 | }
92 |
93 | .field input[type="text"]:focus,
94 | .field input[type="number"]:focus,
95 | .field input[type="color"]:focus,
96 | .field textarea:focus,
97 | .field select:focus {
98 | outline: none;
99 | border-color: rgba(59, 130, 246, 0.75);
100 | box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15);
101 | }
102 |
103 | .field textarea {
104 | resize: vertical;
105 | min-height: 90px;
106 | }
107 |
108 | .field-grid {
109 | display: grid;
110 | grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
111 | gap: 1rem;
112 | }
113 |
114 | .checkbox-field {
115 | flex-direction: row;
116 | align-items: center;
117 | gap: 0.75rem;
118 | margin-top: 0.5rem;
119 | }
120 |
121 | .checkbox-field input[type="checkbox"] {
122 | width: 1.15rem;
123 | height: 1.15rem;
124 | accent-color: #6366f1;
125 | }
126 |
127 | .preset-builder__actions {
128 | margin-top: 1.75rem;
129 | display: flex;
130 | gap: 1rem;
131 | flex-wrap: wrap;
132 | }
133 |
134 | .preset-builder__actions--preview {
135 | margin-top: 1.25rem;
136 | margin-bottom: 1.5rem;
137 | }
138 |
139 | .btn {
140 | border: none;
141 | border-radius: 999px;
142 | padding: 0.75rem 1.6rem;
143 | font-size: 0.95rem;
144 | font-weight: 600;
145 | cursor: pointer;
146 | transition: transform 0.15s ease, box-shadow 0.15s ease, background 0.2s ease;
147 | }
148 |
149 | .btn.primary {
150 | background: linear-gradient(135deg, #6366f1, #22d3ee);
151 | color: #0f172a;
152 | box-shadow: 0 18px 45px -18px rgba(99, 102, 241, 0.6);
153 | }
154 |
155 | .btn.primary:hover {
156 | transform: translateY(-1px);
157 | box-shadow: 0 22px 50px -20px rgba(99, 102, 241, 0.7);
158 | }
159 |
160 | .btn.secondary {
161 | background: rgba(148, 163, 184, 0.15);
162 | color: #e2e8f0;
163 | }
164 |
165 | .btn.secondary:hover {
166 | background: rgba(148, 163, 184, 0.25);
167 | }
168 |
169 | .btn.subtle {
170 | background: transparent;
171 | color: rgba(226, 232, 240, 0.7);
172 | border: 1px dashed rgba(148, 163, 184, 0.3);
173 | }
174 |
175 | .btn.subtle:hover {
176 | border-color: rgba(148, 163, 184, 0.6);
177 | color: #e2e8f0;
178 | }
179 |
180 | .preset-builder__status {
181 | min-height: 1.4rem;
182 | margin: 1rem 0 0;
183 | font-size: 0.9rem;
184 | color: rgba(94, 234, 212, 0.85);
185 | }
186 |
187 | .preset-builder__status[data-tone="warn"] {
188 | color: #fbbf24;
189 | }
190 |
191 | .preset-builder__status[data-tone="error"] {
192 | color: #f87171;
193 | }
194 |
195 | .preset-builder__status[data-tone="success"] {
196 | color: #34d399;
197 | }
198 |
199 | .preset-builder__outputs {
200 | margin-top: 2.25rem;
201 | display: flex;
202 | flex-direction: column;
203 | gap: 0.75rem;
204 | }
205 |
206 | .preset-builder__outputs label {
207 | font-size: 0.85rem;
208 | text-transform: uppercase;
209 | letter-spacing: 0.08em;
210 | color: rgba(148, 163, 184, 0.75);
211 | }
212 |
213 | #liquid-tag-output {
214 | background: rgba(15, 23, 42, 0.65);
215 | border: 1px solid rgba(99, 102, 241, 0.2);
216 | border-radius: 14px;
217 | padding: 1rem 1.25rem;
218 | color: #dbeafe;
219 | font-family: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
220 | line-height: 1.5;
221 | min-height: 110px;
222 | }
223 |
224 | .outputs-actions {
225 | display: flex;
226 | gap: 0.75rem;
227 | flex-wrap: wrap;
228 | }
229 |
230 | .preset-builder__preview .preview-card header {
231 | margin-bottom: 1.5rem;
232 | }
233 |
234 | .preset-builder__preview h2 {
235 | margin: 0;
236 | font-size: 1.35rem;
237 | font-weight: 600;
238 | }
239 |
240 | .preset-builder__preview p {
241 | margin: 0.65rem 0 0;
242 | color: rgba(148, 163, 184, 0.75);
243 | line-height: 1.5;
244 | }
245 |
246 | .viewer-preview {
247 | min-height: 420px;
248 | border-radius: 16px;
249 | border: 1px solid rgba(148, 163, 184, 0.2);
250 | background: rgba(15, 23, 42, 0.7);
251 | padding: 1.5rem;
252 | display: block;
253 | width: 100%;
254 | max-width: 100%;
255 | overflow-x: hidden;
256 | }
257 |
258 | .viewer-preview .svg-viewer-wrapper {
259 | width: 100%;
260 | max-width: 100%;
261 | min-width: 0;
262 | margin: 0 auto;
263 | }
264 |
265 | dialog {
266 | border: none;
267 | border-radius: 20px;
268 | padding: 1.75rem;
269 | max-width: 720px;
270 | width: 90vw;
271 | background: rgba(8, 11, 23, 0.92);
272 | color: #e8ecf6;
273 | box-shadow: 0 30px 60px -30px rgba(2, 6, 23, 0.75);
274 | }
275 |
276 | dialog::backdrop {
277 | background: rgba(9, 11, 21, 0.55);
278 | backdrop-filter: blur(6px);
279 | }
280 |
281 | dialog header h2 {
282 | margin: 0;
283 | font-size: 1.35rem;
284 | }
285 |
286 | dialog header p {
287 | margin: 0.5rem 0 1.25rem;
288 | color: rgba(148, 163, 184, 0.75);
289 | line-height: 1.5;
290 | }
291 |
292 | #yaml-output {
293 | width: 100%;
294 | background: rgba(15, 23, 42, 0.65);
295 | border: 1px solid rgba(148, 163, 184, 0.3);
296 | border-radius: 14px;
297 | padding: 1rem 1.15rem;
298 | color: #dbeafe;
299 | font-family: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
300 | line-height: 1.5;
301 | box-sizing: border-box;
302 | margin-bottom: 1.5rem;
303 | }
304 |
305 | dialog footer {
306 | display: flex;
307 | justify-content: flex-end;
308 | gap: 0.75rem;
309 | }
310 |
311 | @media (max-width: 1100px) {
312 | .preset-builder {
313 | grid-template-columns: 1fr;
314 | }
315 |
316 | .preset-builder__preview .preview-card {
317 | order: -1;
318 | }
319 | }
320 |
321 | @media (max-width: 720px) {
322 | .preset-builder-body {
323 | padding: 2rem 1rem;
324 | }
325 |
326 | .preset-builder__actions {
327 | flex-direction: column;
328 | align-items: stretch;
329 | }
330 |
331 | .outputs-actions {
332 | flex-direction: column;
333 | }
334 |
335 | dialog {
336 | padding: 1.5rem;
337 | }
338 | }
339 |
--------------------------------------------------------------------------------
/assets/svg-viewer/css/svg-viewer.css:
--------------------------------------------------------------------------------
1 | /* SVG Viewer Plugin Styles */
2 |
3 | .svg-viewer-wrapper {
4 | display: flex;
5 | flex-direction: column;
6 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen,
7 | Ubuntu, Cantarell, sans-serif;
8 | border: 1px solid #ddd;
9 | border-radius: 4px;
10 | overflow: hidden;
11 | background-color: #fff;
12 | --svg-viewer-button-fill: #0073aa;
13 | --svg-viewer-button-hover: #005a87;
14 | --svg-viewer-button-border: #0073aa;
15 | --svg-viewer-button-text: #fff;
16 | width: 100%;
17 | max-width: 100%;
18 | min-width: 0;
19 | contain: layout inline-size;
20 | margin-bottom: 1em;
21 | }
22 |
23 | .svg-viewer-main {
24 | display: flex;
25 | flex-direction: column;
26 | gap: 0;
27 | width: 100%;
28 | min-width: 0;
29 | contain: inline-size;
30 | }
31 |
32 | .svg-viewer-main.controls-position-bottom {
33 | flex-direction: column-reverse;
34 | }
35 |
36 | .svg-viewer-main.controls-position-left {
37 | flex-direction: row;
38 | }
39 |
40 | .svg-viewer-main.controls-position-right {
41 | flex-direction: row-reverse;
42 | }
43 |
44 | .svg-viewer-main.controls-position-left,
45 | .svg-viewer-main.controls-position-right {
46 | align-items: stretch;
47 | }
48 |
49 | .svg-viewer-main.controls-position-left .svg-controls,
50 | .svg-viewer-main.controls-position-right .svg-controls {
51 | flex: 0 0 auto;
52 | }
53 |
54 | .svg-viewer-main > .svg-container {
55 | flex: 1 1 auto;
56 | min-width: 0;
57 | max-width: 100%;
58 | }
59 |
60 | .svg-viewer-title,
61 | .svg-viewer-caption {
62 | text-align: center;
63 | font-weight: 600;
64 | margin: 0;
65 | padding: 12px 16px;
66 | }
67 |
68 | .svg-viewer-interaction-caption {
69 | font-weight: 400;
70 | font-size: 0.9em;
71 | color: #555;
72 | background-color: #fafafa;
73 | }
74 |
75 | .svg-viewer-title {
76 | border-bottom: 1px solid #ddd;
77 | }
78 |
79 | .svg-controls {
80 | display: flex;
81 | gap: 10px;
82 | padding: 15px;
83 | background-color: #f5f5f5;
84 | border-bottom: 1px solid #ddd;
85 | flex-wrap: wrap;
86 | align-items: center;
87 | justify-content: flex-start;
88 | }
89 |
90 | .svg-viewer-main.controls-position-bottom .svg-controls {
91 | border-top: 1px solid #ddd;
92 | border-bottom: none;
93 | }
94 |
95 | .svg-viewer-main.controls-position-left .svg-controls,
96 | .svg-viewer-main.controls-position-right .svg-controls {
97 | border-bottom: none;
98 | align-items: flex-start;
99 | justify-content: center;
100 | }
101 |
102 | .svg-viewer-main.controls-position-left.controls-align-alignleft .svg-controls,
103 | .svg-viewer-main.controls-position-right.controls-align-alignleft
104 | .svg-controls {
105 | align-items: flex-start;
106 | }
107 |
108 | .svg-viewer-main.controls-position-left.controls-align-alignright .svg-controls,
109 | .svg-viewer-main.controls-position-right.controls-align-alignright
110 | .svg-controls {
111 | align-items: flex-end;
112 | }
113 |
114 | .svg-viewer-main.controls-position-left.controls-align-aligncenter
115 | .svg-controls,
116 | .svg-viewer-main.controls-position-right.controls-align-aligncenter
117 | .svg-controls {
118 | align-items: center;
119 | }
120 |
121 | .svg-viewer-main.controls-position-left .svg-controls {
122 | border-right: 1px solid #ddd;
123 | }
124 |
125 | .svg-viewer-main.controls-position-right .svg-controls {
126 | border-left: 1px solid #ddd;
127 | }
128 |
129 | .svg-controls.controls-vertical {
130 | flex-direction: column;
131 | gap: 12px;
132 | align-items: stretch;
133 | }
134 |
135 | .svg-controls .zoom-slider-wrapper {
136 | display: inline-flex;
137 | align-items: center;
138 | width: var(--svg-viewer-slider-width, 240px);
139 | padding: 0 4px;
140 | }
141 |
142 | .svg-controls.controls-mode-icon .zoom-slider-wrapper {
143 | width: var(--svg-viewer-slider-width-icon, 200px);
144 | }
145 |
146 | .svg-controls.controls-vertical .zoom-slider-wrapper {
147 | width: 100%;
148 | }
149 |
150 | .svg-controls .zoom-slider {
151 | width: 100%;
152 | height: 6px;
153 | appearance: none;
154 | -webkit-appearance: none;
155 | background: var(--svg-viewer-slider-track, rgba(0, 0, 0, 0.15));
156 | border-radius: 999px;
157 | outline: none;
158 | }
159 |
160 | .svg-controls .zoom-slider::-webkit-slider-thumb {
161 | -webkit-appearance: none;
162 | appearance: none;
163 | width: 18px;
164 | height: 18px;
165 | border-radius: 50%;
166 | background: var(--svg-viewer-button-fill, #0073aa);
167 | border: 2px solid var(--svg-viewer-button-border, #0073aa);
168 | cursor: pointer;
169 | box-shadow: 0 0 0 2px #fff;
170 | }
171 |
172 | .svg-controls .zoom-slider::-moz-range-thumb {
173 | width: 18px;
174 | height: 18px;
175 | border-radius: 50%;
176 | background: var(--svg-viewer-button-fill, #0073aa);
177 | border: 2px solid var(--svg-viewer-button-border, #0073aa);
178 | cursor: pointer;
179 | }
180 |
181 | .svg-controls .zoom-slider:focus-visible {
182 | outline: 2px solid var(--svg-viewer-button-fill, #0073aa);
183 | outline-offset: 2px;
184 | }
185 |
186 | .svg-viewer-btn {
187 | display: inline-flex;
188 | align-items: center;
189 | gap: 6px;
190 | padding: 8px 16px;
191 | background-color: var(--svg-viewer-button-fill, #0073aa);
192 | color: var(--svg-viewer-button-text, #fff);
193 | border: 1px solid var(--svg-viewer-button-border, #0073aa);
194 | border-radius: 4px;
195 | cursor: pointer;
196 | font-size: 14px;
197 | font-weight: 500;
198 | transition: background-color 0.2s ease, border-color 0.2s ease,
199 | transform 0.1s ease;
200 | white-space: nowrap;
201 | }
202 | .svg-viewer-btn.is-disabled,
203 | .svg-viewer-btn:disabled {
204 | background-color: #c9ccd1;
205 | border-color: #b2b5ba;
206 | color: #f1f1f1;
207 | cursor: not-allowed;
208 | opacity: 0.65;
209 | }
210 | .svg-viewer-btn.is-disabled:hover,
211 | .svg-viewer-btn:disabled:hover {
212 | background-color: #c9ccd1;
213 | }
214 |
215 | .svg-viewer-btn .btn-icon {
216 | font-size: 16px;
217 | line-height: 1;
218 | }
219 |
220 | .svg-viewer-btn .btn-text {
221 | font-size: 14px;
222 | line-height: 1.3;
223 | }
224 |
225 | .svg-viewer-btn .btn-icon svg {
226 | display: block;
227 | width: 16px;
228 | height: 16px;
229 | }
230 |
231 | .svg-viewer-btn:hover {
232 | background-color: var(
233 | --svg-viewer-button-hover,
234 | var(--svg-viewer-button-fill, #0073aa)
235 | );
236 | }
237 |
238 | .svg-viewer-btn:active {
239 | transform: scale(0.98);
240 | }
241 |
242 | .svg-controls .divider {
243 | width: 1px;
244 | height: 24px;
245 | background-color: #ddd;
246 | margin: 0 8px;
247 | }
248 |
249 | .svg-controls.controls-vertical .divider {
250 | width: 100%;
251 | height: 1px;
252 | margin: 8px 0;
253 | }
254 |
255 | .zoom-display {
256 | font-size: 14px;
257 | color: #666;
258 | min-width: 60px;
259 | text-align: center;
260 | padding: 4px 8px;
261 | }
262 |
263 | .controls-mode-icon .btn-text {
264 | display: none;
265 | }
266 |
267 | .controls-mode-icon .svg-viewer-btn {
268 | padding: 8px;
269 | justify-content: center;
270 | }
271 |
272 | .controls-mode-icon .svg-viewer-btn .btn-icon svg {
273 | width: 20px;
274 | height: 20px;
275 | }
276 |
277 | .controls-mode-text .btn-icon {
278 | display: none;
279 | }
280 |
281 | .controls-style-compact .svg-viewer-btn {
282 | padding: 6px 12px;
283 | font-size: 13px;
284 | }
285 |
286 | .controls-style-compact .svg-controls {
287 | gap: 8px;
288 | padding: 12px;
289 | }
290 |
291 | .controls-style-labels-on-hover .btn-text {
292 | opacity: 0;
293 | max-width: 0;
294 | overflow: hidden;
295 | transition: opacity 0.2s ease, max-width 0.2s ease;
296 | }
297 |
298 | .controls-style-labels-on-hover .svg-viewer-btn:hover .btn-text,
299 | .controls-style-labels-on-hover .svg-viewer-btn:focus .btn-text,
300 | .controls-style-labels-on-hover .svg-viewer-btn:focus-visible .btn-text {
301 | opacity: 1;
302 | max-width: 200px;
303 | }
304 |
305 | .svg-controls.controls-vertical .svg-viewer-btn {
306 | width: 100%;
307 | justify-content: center;
308 | }
309 |
310 | .svg-controls.controls-align-aligncenter {
311 | justify-content: center;
312 | }
313 |
314 | .svg-controls.controls-align-alignright {
315 | justify-content: flex-end;
316 | }
317 |
318 | .svg-controls.controls-align-alignleft {
319 | justify-content: flex-start;
320 | }
321 |
322 | .svg-controls.controls-vertical.controls-align-alignleft {
323 | align-items: flex-start;
324 | }
325 |
326 | .svg-controls.controls-vertical.controls-align-alignright {
327 | align-items: flex-end;
328 | }
329 |
330 | .svg-controls.controls-vertical.controls-align-aligncenter {
331 | align-items: center;
332 | }
333 |
334 | .svg-viewer-main.controls-align-aligncenter .svg-controls {
335 | justify-content: center;
336 | }
337 |
338 | .svg-viewer-main.controls-align-alignright .svg-controls {
339 | justify-content: flex-end;
340 | }
341 |
342 | .svg-viewer-main.controls-align-alignleft .svg-controls {
343 | justify-content: flex-start;
344 | }
345 |
346 | .svg-controls.controls-vertical .coord-output {
347 | margin-left: 0;
348 | margin-top: 4px;
349 | }
350 |
351 | .svg-controls.controls-vertical .zoom-display {
352 | align-self: center;
353 | }
354 |
355 | .svg-container {
356 | flex: 0 0 auto;
357 | width: 100%;
358 | max-width: 100%;
359 | min-width: 0;
360 | overflow: auto;
361 | background-color: #fff;
362 | position: relative;
363 | display: block;
364 | scroll-behavior: smooth;
365 | }
366 |
367 | .svg-viewer-wrapper.pan-mode-drag .svg-container {
368 | cursor: grab;
369 | touch-action: pan-x pan-y;
370 | }
371 |
372 | .svg-viewer-wrapper.pan-mode-drag .svg-container.is-dragging {
373 | cursor: grabbing;
374 | }
375 |
376 | .svg-viewer-wrapper.pan-mode-drag .svg-container.is-dragging,
377 | .svg-viewer-wrapper.pan-mode-drag .svg-container.is-dragging * {
378 | user-select: none;
379 | }
380 |
381 | .svg-viewer-caption {
382 | border-top: 1px solid #ddd;
383 | }
384 |
385 | .svg-viewport {
386 | display: inline-block;
387 | transform-origin: top left;
388 | transition: none;
389 | width: 100%;
390 | max-width: 100%;
391 | }
392 |
393 | .svg-container svg {
394 | display: block;
395 | background-color: #f3f3f3;
396 | width: 100%;
397 | height: auto;
398 | }
399 |
400 | /* Responsive adjustments */
401 | @media (max-width: 768px) {
402 | .svg-viewer-btn {
403 | padding: 6px 12px;
404 | font-size: 13px;
405 | }
406 |
407 | .svg-viewer-btn .btn-icon {
408 | font-size: 14px;
409 | }
410 |
411 | .svg-controls {
412 | gap: 8px;
413 | padding: 12px;
414 | }
415 |
416 | .zoom-display {
417 | font-size: 12px;
418 | }
419 | }
420 |
421 | /* Fix for WordPress admin/editor environments */
422 | .wp-block-html .svg-viewer-wrapper {
423 | max-width: 100%;
424 | }
425 |
426 | .svg-viewer-wrapper .svg-controls .zoom-display {
427 | display: inline-flex;
428 | align-items: center;
429 | gap: 0.25rem;
430 | justify-content: center;
431 | }
432 |
433 | .svg-viewer-wrapper .svg-controls .coord-output {
434 | margin-left: 0.75rem;
435 | font-family: Menlo, Monaco, Consolas, "Liberation Mono", "Courier New",
436 | monospace;
437 | font-size: 0.85rem;
438 | color: #444;
439 | }
440 |
--------------------------------------------------------------------------------
/lib/jekyll/svg_viewer/tag.rb:
--------------------------------------------------------------------------------
1 | require "json"
2 | require "securerandom"
3 | require "erb"
4 |
5 | module Jekyll
6 | module SvgViewer
7 | class Tag < Liquid::Tag
8 | include ERB::Util
9 |
10 | ATTRIBUTE_REGEX = /
11 | (?[a-zA-Z0-9_]+)
12 | \s*=\s*
13 | (?
14 | "(?:[^"\\]|\\.)*" |
15 | '(?:[^'\\]|\\.)*' |
16 | [^\s]+
17 | )
18 | /x.freeze
19 |
20 | ATTR_ALIASES = {
21 | "button_bg" => "button_fill",
22 | "button_background" => "button_fill",
23 | "button_fg" => "button_foreground",
24 | "pan" => "pan_mode",
25 | "zoom_behavior" => "zoom_mode",
26 | "zoom_interaction" => "zoom_mode",
27 | "initial_zoom" => "zoom"
28 | }.freeze
29 |
30 | BUTTONS = {
31 | "zoom_in" => {
32 | "class" => "zoom-in-btn",
33 | "icon" => '',
34 | "text" => "Zoom In",
35 | "title" => "Zoom In (Ctrl +)",
36 | "requires_show_coords" => false
37 | },
38 | "zoom_out" => {
39 | "class" => "zoom-out-btn",
40 | "icon" => '',
41 | "text" => "Zoom Out",
42 | "title" => "Zoom Out (Ctrl -)",
43 | "requires_show_coords" => false
44 | },
45 | "reset" => {
46 | "class" => "reset-zoom-btn",
47 | "icon" => '',
48 | "text" => "Reset Zoom",
49 | "title" => "Reset Zoom",
50 | "requires_show_coords" => false
51 | },
52 | "center" => {
53 | "class" => "center-view-btn",
54 | "icon" => '',
55 | "text" => "Center View",
56 | "title" => "Center View",
57 | "requires_show_coords" => false
58 | },
59 | "coords" => {
60 | "class" => "coord-copy-btn",
61 | "icon" => "📍",
62 | "text" => "Copy Center",
63 | "title" => "Copy current center coordinates",
64 | "requires_show_coords" => true
65 | }
66 | }.freeze
67 |
68 | MODE_OPTIONS = %w[icon text both].freeze
69 | STYLE_OPTIONS = %w[compact labels-on-hover labels_on_hover].freeze
70 | HIDDEN_OPTIONS = %w[hidden none].freeze
71 | ALIGNMENT_OPTIONS = %w[alignleft aligncenter alignright].freeze
72 | AVAILABLE_BUTTONS = BUTTONS.keys.freeze
73 |
74 | def initialize(tag_name, markup, tokens)
75 | super
76 | @raw_markup = markup.to_s
77 | end
78 |
79 | def render(context)
80 | site = context.registers[:site]
81 | page = context.registers[:page]
82 | config = Config.for(site)
83 |
84 | attrs = merge_defaults(config["defaults"], parse_attributes(@raw_markup))
85 | attrs["show_coords"] = truthy?(attrs["show_coords"])
86 |
87 | validate_src!(attrs["src"])
88 |
89 | viewer_id = "svg-viewer-#{SecureRandom.hex(6)}"
90 |
91 | zoom_values = normalize_zoom_values(attrs)
92 | interaction = resolve_interaction(attrs["pan_mode"], attrs["zoom_mode"])
93 | controls = parse_controls_config(attrs["controls_position"], attrs["controls_buttons"], attrs["show_coords"])
94 | controls_markup = render_controls_markup(
95 | viewer_id,
96 | controls,
97 | zoom_values[:initial_percent],
98 | zoom_values[:min_percent],
99 | zoom_values[:max_percent],
100 | zoom_values[:step_percent]
101 | )
102 |
103 | wrapper_classes = build_wrapper_classes(attrs, controls, interaction)
104 | main_classes = main_container_classes(controls)
105 | wrapper_style = build_wrapper_style(attrs)
106 |
107 | AssetManager.flag_page_for_assets(site, page)
108 |
109 | interaction_caption = interaction[:messages].empty? ? "" : %(#{interaction[:messages].map { |m| h(m) }.join("
")})
110 | caption_markup = caption_html(attrs["caption"])
111 | title_markup = title_html(attrs["title"])
112 |
113 | <<~HTML
114 |
115 | #{title_markup}
116 |
117 | #{controls_markup}
118 |
119 |
120 |
121 |
122 | #{interaction_caption}
123 | #{caption_markup}
124 |
125 | #{viewer_bootstrap(viewer_id, attrs, zoom_values, interaction, controls, site)}
126 | HTML
127 | end
128 |
129 | private
130 |
131 | def parse_attributes(markup)
132 | return {} if markup.strip.empty?
133 |
134 | markup.scan(ATTRIBUTE_REGEX).each_with_object({}) do |match, attrs|
135 | key = match[0]
136 | value = unquote(match[1])
137 | canonical = ATTR_ALIASES.fetch(key, key)
138 | attrs[canonical] = value
139 | end
140 | end
141 |
142 | def unquote(value)
143 | return "" if value.nil?
144 |
145 | if value.start_with?('"') && value.end_with?('"')
146 | value[1...-1]
147 | elsif value.start_with?("'") && value.end_with?("'")
148 | value[1...-1]
149 | else
150 | value
151 | end
152 | end
153 |
154 | def merge_defaults(defaults, overrides)
155 | overrides = overrides.dup
156 | defaults.each_with_object(overrides) do |(key, val), merged|
157 | merged[key] = overrides.key?(key) ? overrides[key] : val
158 | end
159 | end
160 |
161 | def validate_src!(src)
162 | raise Liquid::ArgumentError, "svg_viewer: src attribute is required" if src.to_s.strip.empty?
163 | end
164 |
165 | def normalize_zoom_values(attrs)
166 | initial = safe_number(attrs["zoom"], 100.0)
167 | min = safe_number(attrs["min_zoom"], 25.0)
168 | max = [safe_number(attrs["max_zoom"], 800.0), initial].max
169 | step = safe_number(attrs["zoom_step"], 10.0)
170 |
171 | {
172 | initial: initial / 100.0,
173 | min: min / 100.0,
174 | max: max / 100.0,
175 | step: [step / 100.0, 0.001].max,
176 | initial_percent: initial.round,
177 | min_percent: min.round,
178 | max_percent: max.round,
179 | step_percent: [step.round, 1].max
180 | }
181 | end
182 |
183 | def resolve_interaction(pan_value, zoom_value)
184 | pan_mode = normalize_pan(pan_value)
185 | zoom_mode = normalize_zoom(zoom_value)
186 | messages = []
187 |
188 | pan_mode = "drag" if zoom_mode == "scroll" && pan_mode == "scroll"
189 |
190 | case zoom_mode
191 | when "click"
192 | messages << "Cmd/Ctrl-click to zoom in, Option/Alt-click to zoom out."
193 | when "scroll"
194 | messages << "Scroll up to zoom in, scroll down to zoom out."
195 | end
196 |
197 | if pan_mode == "drag"
198 | if zoom_mode == "scroll"
199 | messages << "Drag to pan around the image while scrolling zooms."
200 | else
201 | messages << "Drag to pan around the image."
202 | end
203 | end
204 |
205 | { pan_mode: pan_mode, zoom_mode: zoom_mode, messages: messages.uniq }
206 | end
207 |
208 | def parse_controls_config(position, buttons_setting, show_coords)
209 | position = %w[top bottom left right].include?(position) ? position : "top"
210 | normalized_setting = buttons_setting.to_s.strip
211 | normalized_setting = "both" if normalized_setting.empty?
212 | tokens = normalized_setting.downcase.tr(":", ",").split(",").map(&:strip).reject(&:empty?)
213 |
214 | has_slider = tokens.include?("slider")
215 | slider_explicit_zoom_in = tokens.include?("zoom_in")
216 | slider_explicit_zoom_out = tokens.include?("zoom_out")
217 |
218 | mode = "both"
219 | styles = []
220 | alignment = "alignleft"
221 | buttons = default_buttons(show_coords)
222 | is_custom = false
223 |
224 | token = normalized_setting.downcase
225 | if HIDDEN_OPTIONS.include?(token)
226 | mode = "hidden"
227 | buttons = []
228 | elsif token == "minimal"
229 | buttons = %w[zoom_in zoom_out center]
230 | buttons << "coords" if show_coords
231 | elsif token == "slider"
232 | has_slider = true
233 | buttons = default_buttons_without_zoom(show_coords)
234 | elsif STYLE_OPTIONS.include?(token)
235 | styles << token.tr("_", "-")
236 | elsif ALIGNMENT_OPTIONS.include?(token)
237 | alignment = token
238 | elsif MODE_OPTIONS.include?(token)
239 | mode = token
240 | elsif token == "custom" || normalized_setting.include?(",")
241 | is_custom = true
242 | end
243 |
244 | if is_custom
245 | parts = normalized_setting.tr(":", ",").split(",").map(&:strip).reject(&:empty?)
246 | parts.shift if parts.first&.casecmp?("custom")
247 |
248 | custom_mode = nil
249 | custom_styles = []
250 |
251 | if parts.first&.casecmp?("slider")
252 | has_slider = true
253 | parts.shift
254 | end
255 |
256 | if parts.first && MODE_OPTIONS.include?(parts.first.downcase)
257 | custom_mode = parts.shift.downcase
258 | end
259 |
260 | if parts.first && ALIGNMENT_OPTIONS.include?(parts.first.downcase)
261 | alignment = parts.shift.downcase
262 | end
263 |
264 | custom_styles += parts.select { |p| STYLE_OPTIONS.include?(p.downcase) }.map { |p| p.tr("_", "-") }
265 | parts -= custom_styles
266 |
267 | if mode != "hidden"
268 | custom_buttons = parts.map(&:downcase).uniq.select do |key|
269 | next false if key == "slider"
270 | next false if key == "coords" && !show_coords
271 |
272 | AVAILABLE_BUTTONS.include?(key)
273 | end
274 | if custom_buttons.any?
275 | buttons = custom_buttons
276 | elsif has_slider
277 | buttons = default_buttons_without_zoom(show_coords)
278 | end
279 | end
280 |
281 | mode = custom_mode if custom_mode
282 | styles.concat(custom_styles)
283 | end
284 |
285 | if mode != "hidden"
286 | if show_coords
287 | buttons << "coords" unless buttons.include?("coords")
288 | else
289 | buttons = buttons.reject { |b| b == "coords" }
290 | end
291 |
292 | buttons = default_buttons(show_coords) if buttons.empty?
293 | else
294 | buttons = []
295 | end
296 |
297 | styles = styles.map { |s| s.tr("_", "-").downcase }.uniq
298 |
299 | if has_slider && !slider_explicit_zoom_in
300 | buttons = buttons.reject { |b| b == "zoom_in" }
301 | end
302 |
303 | if has_slider && !slider_explicit_zoom_out
304 | buttons = buttons.reject { |b| b == "zoom_out" }
305 | end
306 |
307 | {
308 | position: position,
309 | mode: mode,
310 | styles: styles,
311 | alignment: ALIGNMENT_OPTIONS.include?(alignment) ? alignment : "alignleft",
312 | buttons: buttons,
313 | has_slider: has_slider
314 | }
315 | end
316 |
317 | def default_buttons(show_coords)
318 | list = %w[zoom_in zoom_out reset center]
319 | list << "coords" if show_coords
320 | list
321 | end
322 |
323 | def default_buttons_without_zoom(show_coords)
324 | default_buttons(show_coords).reject { |b| %w[zoom_in zoom_out].include?(b) }
325 | end
326 |
327 | def render_controls_markup(viewer_id, config, initial_percent, min_percent, max_percent, step_percent)
328 | return "" if config[:mode] == "hidden"
329 |
330 | classes = ["svg-controls", "controls-mode-#{config[:mode]}", "controls-align-#{config[:alignment]}"]
331 | classes.concat(config[:styles].map { |s| "controls-style-#{s}" })
332 | classes << "controls-vertical" if %w[left right].include?(config[:position])
333 | class_attribute = classes.map { |cls| h(cls) }.uniq.join(" ")
334 |
335 | buttons = config[:buttons].map { |key| BUTTONS[key] }.compact
336 | has_coords_button = config[:buttons].include?("coords")
337 |
338 | slider_markup = ""
339 | if config[:has_slider]
340 | slider_markup = <<~HTML
341 |
350 | HTML
351 | end
352 |
353 | buttons_markup = buttons.map do |definition|
354 | <<~HTML
355 |
363 | HTML
364 | end.join
365 |
366 | coord_output =
367 | if has_coords_button
368 | %()
369 | else
370 | ""
371 | end
372 |
373 | <<~HTML
374 |
375 | #{slider_markup}
376 | #{buttons_markup}
377 | #{coord_output}
378 |
379 |
380 | #{initial_percent}%
381 |
382 |
383 | HTML
384 | end
385 |
386 | def build_wrapper_classes(attrs, controls, interaction)
387 | classes = ["svg-viewer-wrapper", "controls-position-#{controls[:position]}", "controls-mode-#{controls[:mode]}", "pan-mode-#{interaction[:pan_mode]}", "zoom-mode-#{interaction[:zoom_mode]}"]
388 | classes.concat(controls[:styles].map { |s| "controls-style-#{s}" })
389 | custom = attrs["class"].to_s.strip
390 | classes << custom unless custom.empty?
391 | classes.map { |cls| h(cls) }.uniq.join(" ")
392 | end
393 |
394 | def main_container_classes(controls)
395 | classes = ["svg-viewer-main", "controls-position-#{controls[:position]}", "controls-align-#{controls[:alignment]}"]
396 | classes.concat(controls[:styles].map { |s| "controls-style-#{s}" })
397 | classes.map { |cls| h(cls) }.uniq.join(" ")
398 | end
399 |
400 | def build_wrapper_style(attrs)
401 | declarations = ["width: 100%", "max-width: 100%", "min-width: 0"]
402 | declarations.concat(button_color_custom_properties(attrs["button_fill"], attrs["button_border"], attrs["button_foreground"]))
403 | declarations.join("; ")
404 | end
405 |
406 | def button_color_custom_properties(fill, border, foreground)
407 | declarations = []
408 |
409 | fill_color = sanitize_hex_color(fill)
410 | border_color = sanitize_hex_color(border)
411 | foreground_color = sanitize_hex_color(foreground)
412 |
413 | if fill_color
414 | declarations << "--svg-viewer-button-fill: #{fill_color}"
415 | if (hover = adjust_color_brightness(fill_color, -12))
416 | declarations << "--svg-viewer-button-hover: #{hover}"
417 | end
418 | end
419 |
420 | border_color ||= fill_color
421 | declarations << "--svg-viewer-button-border: #{border_color}" if border_color
422 | declarations << "--svg-viewer-button-text: #{foreground_color}" if foreground_color
423 | declarations
424 | end
425 |
426 | def sanitize_hex_color(color)
427 | return nil if color.to_s.strip.empty?
428 | hex = color.to_s.strip
429 | hex = "##{hex}" unless hex.start_with?("#")
430 | return nil unless hex.match?(/^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/)
431 | hex.downcase
432 | end
433 |
434 | def adjust_color_brightness(hex_color, percentage)
435 | hex = hex_color.delete_prefix("#")
436 | hex = hex.chars.map { |ch| ch * 2 }.join if hex.length == 3
437 | return nil unless hex.length == 6
438 |
439 | components = [hex[0..1], hex[2..3], hex[4..5]].map { |pair| pair.to_i(16) }
440 | factor = percentage / 100.0
441 | components.map! do |component|
442 | value = component + (percentage.positive? ? (255 - component) * factor : component * factor)
443 | [[value.round, 0].max, 255].min
444 | end
445 |
446 | format("#%02x%02x%02x", *components)
447 | end
448 |
449 | def viewer_bootstrap(viewer_id, attrs, zoom_values, interaction, controls, site)
450 | config_hash = site.config.fetch("svg_viewer", {})
451 | locale = config_hash.is_a?(Hash) ? config_hash["locale"] : nil
452 | i18n_payload = AssetManager.locale_payload(site)
453 |
454 | options = {
455 | viewerId: viewer_id,
456 | svgUrl: attrs["src"],
457 | initialZoom: zoom_values[:initial],
458 | minZoom: zoom_values[:min],
459 | maxZoom: zoom_values[:max],
460 | zoomStep: zoom_values[:step],
461 | centerX: numeric_value_or_nil(attrs["center_x"]),
462 | centerY: numeric_value_or_nil(attrs["center_y"]),
463 | showCoordinates: attrs["show_coords"],
464 | panMode: interaction[:pan_mode],
465 | zoomMode: interaction[:zoom_mode],
466 | controlsConfig: controls,
467 | buttonFill: attrs["button_fill"],
468 | buttonBorder: attrs["button_border"],
469 | buttonForeground: attrs["button_foreground"],
470 | locale: locale || "en",
471 | i18n: i18n_payload
472 | }
473 |
474 | <<~HTML
475 |
486 | HTML
487 | end
488 |
489 | def truthy?(value)
490 | return value if value == true || value == false
491 | %w[true 1 yes on].include?(value.to_s.strip.downcase)
492 | end
493 |
494 | def safe_number(value, default)
495 | Float(value.to_s.strip)
496 | rescue ArgumentError, TypeError
497 | default
498 | end
499 |
500 | def numeric_value_or_nil(value)
501 | Float(value.to_s.strip)
502 | rescue ArgumentError, TypeError
503 | nil
504 | end
505 |
506 | def normalize_pan(value)
507 | value.to_s.strip.downcase == "drag" ? "drag" : "scroll"
508 | end
509 |
510 | def normalize_zoom(value)
511 | normalized = value.to_s.strip.downcase.tr(" -", "_")
512 | return normalized if %w[click scroll].include?(normalized)
513 | "super_scroll"
514 | end
515 |
516 | def title_html(title)
517 | return "" if title.to_s.strip.empty?
518 | %(#{title})
519 | end
520 |
521 | def caption_html(caption)
522 | return "" if caption.to_s.strip.empty?
523 | %(#{caption})
524 | end
525 | end
526 | end
527 | end
528 |
529 |
--------------------------------------------------------------------------------
/assets/svg-viewer/preview/preset-builder.js:
--------------------------------------------------------------------------------
1 | (() => {
2 | const defaults = window.__SVG_VIEWER_DEFAULTS__ || {};
3 | const form = document.getElementById("preset-form");
4 | const previewContainer = document.getElementById("viewer-preview");
5 | const statusEl = document.getElementById("builder-status");
6 | const liquidOutput = document.getElementById("liquid-tag-output");
7 | const copyLiquidBtn = document.getElementById("copy-liquid");
8 | const loadPreviewBtn = document.getElementById("load-preview");
9 | const captureCenterBtn = document.getElementById("capture-center");
10 | const showYamlBtn = document.getElementById("show-yaml");
11 | const yamlModal = document.getElementById("yaml-modal");
12 | const yamlOutput = document.getElementById("yaml-output");
13 | const copyYamlBtn = document.getElementById("copy-yaml");
14 |
15 | if (!form || !previewContainer) return;
16 |
17 | const state = {
18 | src: "",
19 | height: defaults["height"] || "600px",
20 | zoom: defaults["zoom"] || "100",
21 | min_zoom: defaults["min_zoom"] || "25",
22 | max_zoom: defaults["max_zoom"] || "800",
23 | zoom_step: defaults["zoom_step"] || "10",
24 | center_x: "",
25 | center_y: "",
26 | show_coords: Boolean(defaults["show_coords"]),
27 | title: defaults["title"] || "",
28 | caption: defaults["caption"] || "",
29 | controls_position: defaults["controls_position"] || "top",
30 | controls_buttons: defaults["controls_buttons"] || "both",
31 | button_fill: defaults["button_fill"] || "",
32 | button_border: defaults["button_border"] || "",
33 | button_foreground: defaults["button_foreground"] || "",
34 | pan_mode: defaults["pan_mode"] || "",
35 | zoom_mode: defaults["zoom_mode"] || "",
36 | };
37 |
38 | const baseUrl = (window.__SVG_VIEWER_BASEURL__ || "").replace(/\/+$/, "");
39 | fetch(`${baseUrl}/assets/svg-viewer/i18n/locales.json`)
40 | .then((response) => (response.ok ? response.json() : {}))
41 | .then((data) => {
42 | window.__SVG_VIEWER_I18N__ = data;
43 | })
44 | .catch(() => {
45 | window.__SVG_VIEWER_I18N__ = {};
46 | });
47 |
48 | const fieldElements = Array.from(form.querySelectorAll("[data-setting]"));
49 | fieldElements.forEach((field) => {
50 | const key = field.dataset.setting;
51 | if (!(key in state)) return;
52 | setFieldValue(field, state[key]);
53 | field.dataset.originalValue = field.value;
54 | field.addEventListener(
55 | field.type === "checkbox" ? "change" : "input",
56 | () => {
57 | state[key] = readFieldValue(field);
58 | updateOutputs();
59 | }
60 | );
61 | });
62 |
63 | let currentViewerId = null;
64 | let currentViewer = null;
65 |
66 | loadPreviewBtn.addEventListener("click", (event) => {
67 | event.preventDefault();
68 | renderPreview();
69 | });
70 |
71 | captureCenterBtn.addEventListener("click", (event) => {
72 | event.preventDefault();
73 | if (!currentViewer) {
74 | flashStatus(
75 | "Load the preview before capturing the current view.",
76 | "warn"
77 | );
78 | return;
79 | }
80 | const center = currentViewer.getVisibleCenterPoint
81 | ? currentViewer.getVisibleCenterPoint()
82 | : null;
83 | if (!center) {
84 | flashStatus("Unable to read the center point from the preview.", "error");
85 | return;
86 | }
87 | state.center_x = center.x.toFixed(2);
88 | state.center_y = center.y.toFixed(2);
89 | state.zoom = (currentViewer.currentZoom * 100).toFixed(0);
90 | syncFields();
91 | updateOutputs();
92 | flashStatus("Captured the current view. Fields updated.", "success");
93 | });
94 |
95 | copyLiquidBtn.addEventListener("click", async (event) => {
96 | event.preventDefault();
97 | if (!liquidOutput.value.trim()) {
98 | flashStatus(
99 | "Nothing to copy yet. Add a source and load the preview first.",
100 | "warn"
101 | );
102 | return;
103 | }
104 | await copyText(liquidOutput.value);
105 | flashStatus("Liquid tag copied to clipboard.", "success");
106 | });
107 |
108 | showYamlBtn.addEventListener("click", (event) => {
109 | event.preventDefault();
110 | const yaml = buildYamlSnippet();
111 | yamlOutput.value = yaml;
112 | openDialog(yamlModal);
113 | });
114 |
115 | copyYamlBtn.addEventListener("click", async (event) => {
116 | event.preventDefault();
117 | await copyText(yamlOutput.value);
118 | flashStatus("YAML snippet copied to clipboard.", "success");
119 | closeDialog(yamlModal);
120 | });
121 |
122 | if (yamlModal) {
123 | yamlModal.addEventListener("cancel", (event) => {
124 | event.preventDefault();
125 | closeDialog(yamlModal);
126 | });
127 | }
128 |
129 | updateOutputs();
130 |
131 | function renderPreview() {
132 | if (!state.src.trim()) {
133 | flashStatus(
134 | "Provide an SVG source URL before loading the preview.",
135 | "error"
136 | );
137 | return;
138 | }
139 |
140 | destroyCurrentViewer();
141 |
142 | const viewerId = `svg-viewer-preview-${Date.now()}`;
143 | currentViewerId = viewerId;
144 |
145 | const wrapper = document.createElement("div");
146 | wrapper.id = viewerId;
147 | wrapper.className = buildWrapperClasses();
148 | const wrapperStyle = buildWrapperStyle();
149 | if (wrapperStyle) {
150 | wrapper.style.cssText = wrapperStyle;
151 | }
152 |
153 | const controlsConfig = parseControlsConfig(Boolean(state.show_coords));
154 | const mainClasses = buildMainClasses(controlsConfig);
155 | const controlsMarkup = buildControlsMarkup(viewerId, controlsConfig);
156 | const containerMarkup = `
157 |
158 | ${controlsMarkup}
159 |
162 |
163 |
164 |
165 | `;
166 |
167 | wrapper.innerHTML = `
168 | ${
169 | state.title
170 | ? `${escapeHtml(state.title)}`
171 | : ""
172 | }
173 | ${containerMarkup}
174 | ${
175 | state.caption
176 | ? `${escapeHtml(state.caption)}`
177 | : ""
178 | }
179 | `;
180 |
181 | previewContainer.innerHTML = "";
182 | previewContainer.appendChild(wrapper);
183 |
184 | ensureViewerScript(() => {
185 | const options = viewerOptions(viewerId);
186 | try {
187 | window.svgViewerInstances ||= {};
188 | currentViewer = new window.SVGViewer(options);
189 | window.svgViewerInstances[viewerId] = currentViewer;
190 | flashStatus("Preview loaded successfully.", "success");
191 | } catch (error) {
192 | flashStatus(
193 | "Failed to initialize the viewer. Check the console for details.",
194 | "error"
195 | );
196 | console.error("[SVG Viewer Preview]", error);
197 | }
198 | });
199 |
200 | updateOutputs();
201 | }
202 |
203 | function destroyCurrentViewer() {
204 | if (currentViewer && typeof currentViewer.destroy === "function") {
205 | try {
206 | currentViewer.destroy();
207 | } catch (error) {
208 | console.warn("[SVG Viewer Preview] destroy failed", error);
209 | }
210 | }
211 | if (currentViewerId && window.svgViewerInstances) {
212 | delete window.svgViewerInstances[currentViewerId];
213 | }
214 | currentViewer = null;
215 | currentViewerId = null;
216 | }
217 |
218 | function viewerOptions(viewerId) {
219 | return {
220 | viewerId,
221 | svgUrl: state.src.trim(),
222 | initialZoom: clampZoom(state.zoom) / 100,
223 | minZoom: clampZoom(state.min_zoom) / 100,
224 | maxZoom: Math.max(
225 | clampZoom(state.max_zoom) / 100,
226 | clampZoom(state.zoom) / 100
227 | ),
228 | zoomStep: Math.max(clampZoom(state.zoom_step) / 100, 0.01),
229 | centerX: parseMaybeFloat(state.center_x),
230 | centerY: parseMaybeFloat(state.center_y),
231 | showCoordinates: Boolean(state.show_coords),
232 | panMode: normalizePan(state.pan_mode),
233 | zoomMode: normalizeZoom(state.zoom_mode),
234 | controlsConfig: parseControlsConfig(Boolean(state.show_coords)),
235 | buttonFill: state.button_fill || undefined,
236 | buttonBorder: state.button_border || undefined,
237 | buttonForeground: state.button_foreground || undefined,
238 | locale: "en",
239 | i18n: window.__SVG_VIEWER_I18N__ || {},
240 | };
241 | }
242 |
243 | function parseControlsConfig(showCoords) {
244 | return parseControls(
245 | state.controls_position,
246 | state.controls_buttons,
247 | showCoords
248 | );
249 | }
250 |
251 | function updateOutputs() {
252 | liquidOutput.value = buildLiquidTag();
253 | }
254 |
255 | function buildLiquidTag() {
256 | if (!state.src.trim()) return "";
257 |
258 | const attributes = [];
259 | attributes.push(`src="${escapeQuotes(state.src.trim())}"`);
260 |
261 | const keys = [
262 | "height",
263 | "zoom",
264 | "min_zoom",
265 | "max_zoom",
266 | "zoom_step",
267 | "center_x",
268 | "center_y",
269 | "show_coords",
270 | "title",
271 | "caption",
272 | "controls_position",
273 | "controls_buttons",
274 | "button_fill",
275 | "button_border",
276 | "button_foreground",
277 | "pan_mode",
278 | "zoom_mode",
279 | ];
280 |
281 | keys.forEach((key) => {
282 | const value = state[key];
283 | const defaultValue = defaults[key];
284 |
285 | if (value === undefined || value === null || value === "") {
286 | return;
287 | }
288 |
289 | if (key === "show_coords") {
290 | if (Boolean(value) === Boolean(defaultValue)) return;
291 | attributes.push(`${key}="true"`);
292 | return;
293 | }
294 |
295 | if (String(value) === String(defaultValue)) {
296 | return;
297 | }
298 |
299 | attributes.push(`${key}="${escapeQuotes(value)}"`);
300 | });
301 |
302 | return `{% svg_viewer ${attributes.join(" ")} %}`;
303 | }
304 |
305 | function buildYamlSnippet() {
306 | const yamlLines = ["svg_viewer:", " defaults:"];
307 | const entries = {
308 | height: state.height,
309 | zoom: state.zoom,
310 | min_zoom: state.min_zoom,
311 | max_zoom: state.max_zoom,
312 | zoom_step: state.zoom_step,
313 | show_coords: Boolean(state.show_coords),
314 | title: state.title,
315 | caption: state.caption,
316 | controls_position: state.controls_position,
317 | controls_buttons: state.controls_buttons,
318 | button_fill: state.button_fill,
319 | button_border: state.button_border,
320 | button_foreground: state.button_foreground,
321 | pan_mode: state.pan_mode,
322 | zoom_mode: state.zoom_mode,
323 | };
324 |
325 | Object.entries(entries).forEach(([key, value]) => {
326 | if (value === undefined || value === null || value === "") {
327 | return;
328 | }
329 |
330 | if (typeof value === "boolean") {
331 | yamlLines.push(` ${key}: ${value}`);
332 | } else {
333 | yamlLines.push(` ${key}: "${escapeQuotes(String(value))}"`);
334 | }
335 | });
336 |
337 | return yamlLines.join("\n");
338 | }
339 |
340 | function parseControls(position, buttonsSetting, showCoords) {
341 | const pos = ["top", "bottom", "left", "right"].includes(position)
342 | ? position
343 | : "top";
344 | const raw = buttonsSetting || "both";
345 | const tokens = raw
346 | .toLowerCase()
347 | .replace(/:/g, ",")
348 | .split(",")
349 | .map((t) => t.trim())
350 | .filter(Boolean);
351 |
352 | let hasSlider = tokens.includes("slider");
353 | let mode = "both";
354 | let alignment = "alignleft";
355 | const styles = [];
356 | let buttons = showCoords
357 | ? ["zoom_in", "zoom_out", "reset", "center", "coords"]
358 | : ["zoom_in", "zoom_out", "reset", "center"];
359 | let isCustom = false;
360 |
361 | const hiddenOptions = ["hidden", "none"];
362 | const styleOptions = ["compact", "labels_on_hover", "labels-on-hover"];
363 | const modeOptions = ["icon", "text", "both"];
364 | const alignmentOptions = ["alignleft", "aligncenter", "alignright"];
365 |
366 | const normalized = raw.toLowerCase();
367 | if (hiddenOptions.includes(normalized)) {
368 | mode = "hidden";
369 | buttons = [];
370 | } else if (normalized === "minimal") {
371 | buttons = showCoords
372 | ? ["zoom_in", "zoom_out", "center", "coords"]
373 | : ["zoom_in", "zoom_out", "center"];
374 | } else if (normalized === "slider") {
375 | hasSlider = true;
376 | buttons = showCoords
377 | ? ["reset", "center", "coords"]
378 | : ["reset", "center"];
379 | } else if (styleOptions.includes(normalized)) {
380 | styles.push(normalized.replace("_", "-"));
381 | } else if (alignmentOptions.includes(normalized)) {
382 | alignment = normalized;
383 | } else if (modeOptions.includes(normalized)) {
384 | mode = normalized;
385 | } else if (normalized === "custom" || normalized.includes(",")) {
386 | isCustom = true;
387 | }
388 |
389 | if (isCustom) {
390 | let parts = raw
391 | .replace(/:/g, ",")
392 | .split(",")
393 | .map((t) => t.trim())
394 | .filter(Boolean);
395 | if (parts[0] && parts[0].toLowerCase() === "custom") {
396 | parts = parts.slice(1);
397 | }
398 |
399 | if (parts[0] && parts[0].toLowerCase() === "slider") {
400 | hasSlider = true;
401 | parts = parts.slice(1);
402 | }
403 |
404 | if (parts[0] && modeOptions.includes(parts[0].toLowerCase())) {
405 | mode = parts[0].toLowerCase();
406 | parts = parts.slice(1);
407 | }
408 |
409 | if (parts[0] && alignmentOptions.includes(parts[0].toLowerCase())) {
410 | alignment = parts[0].toLowerCase();
411 | parts = parts.slice(1);
412 | }
413 |
414 | const styleTokens = parts.filter((token) =>
415 | styleOptions.includes(token.toLowerCase())
416 | );
417 | styleTokens.forEach((token) => {
418 | styles.push(token.replace("_", "-").toLowerCase());
419 | });
420 | parts = parts.filter(
421 | (token) => !styleOptions.includes(token.toLowerCase())
422 | );
423 |
424 | const customButtons = [];
425 | parts.forEach((token) => {
426 | const key = token.toLowerCase();
427 | if (key === "slider") {
428 | hasSlider = true;
429 | return;
430 | }
431 | if (key === "coords" && !showCoords) {
432 | return;
433 | }
434 | if (
435 | ["zoom_in", "zoom_out", "reset", "center", "coords"].includes(key) &&
436 | !customButtons.includes(key)
437 | ) {
438 | customButtons.push(key);
439 | }
440 | });
441 |
442 | if (mode !== "hidden") {
443 | if (customButtons.length > 0) {
444 | buttons = customButtons;
445 | } else if (hasSlider) {
446 | buttons = showCoords
447 | ? ["reset", "center", "coords"]
448 | : ["reset", "center"];
449 | }
450 | } else {
451 | buttons = [];
452 | }
453 | }
454 |
455 | if (mode !== "hidden") {
456 | if (showCoords && !buttons.includes("coords")) {
457 | buttons.push("coords");
458 | }
459 | if (!showCoords) {
460 | buttons = buttons.filter((key) => key !== "coords");
461 | }
462 | if (buttons.length === 0) {
463 | buttons = showCoords
464 | ? ["zoom_in", "zoom_out", "reset", "center", "coords"]
465 | : ["zoom_in", "zoom_out", "reset", "center"];
466 | }
467 | }
468 |
469 | if (hasSlider) {
470 | if (!raw.includes("zoom_in")) {
471 | buttons = buttons.filter((key) => key !== "zoom_in");
472 | }
473 | if (!raw.includes("zoom_out")) {
474 | buttons = buttons.filter((key) => key !== "zoom_out");
475 | }
476 | }
477 |
478 | return {
479 | position: pos,
480 | mode,
481 | styles: [...new Set(styles)],
482 | alignment,
483 | buttons,
484 | has_slider: hasSlider,
485 | };
486 | }
487 |
488 | function buildControlsMarkup(viewerId, config) {
489 | const resolvedConfig =
490 | config || parseControlsConfig(Boolean(state.show_coords));
491 | if (resolvedConfig.mode === "hidden") {
492 | return "";
493 | }
494 |
495 | const classes = [
496 | "svg-controls",
497 | `controls-mode-${resolvedConfig.mode}`,
498 | `controls-align-${resolvedConfig.alignment}`,
499 | ];
500 | resolvedConfig.styles.forEach((style) => {
501 | classes.push(`controls-style-${style}`);
502 | });
503 | if (["left", "right"].includes(resolvedConfig.position)) {
504 | classes.push("controls-vertical");
505 | }
506 |
507 | const sliderMarkup = resolvedConfig.has_slider
508 | ? `
509 |
519 | `
520 | : "";
521 |
522 | const buttonMarkup = resolvedConfig.buttons
523 | .map((key) => buttonDefinition(key))
524 | .filter(Boolean)
525 | .map(
526 | (definition) => `
527 |
535 | `
536 | )
537 | .join("");
538 |
539 | const coordOutput = resolvedConfig.buttons.includes("coords")
540 | ? ``
541 | : "";
542 |
543 | return `
544 |
545 | ${sliderMarkup}
546 | ${buttonMarkup}
547 | ${coordOutput}
548 |
549 |
550 | ${clampZoom(
551 | state.zoom
552 | )}%
553 |
554 |
555 | `;
556 | }
557 |
558 | function buttonDefinition(key) {
559 | const definitions = {
560 | zoom_in: {
561 | class: "zoom-in-btn",
562 | icon: '',
563 | text: "Zoom In",
564 | title: "Zoom In (Ctrl +)",
565 | },
566 | zoom_out: {
567 | class: "zoom-out-btn",
568 | icon: '',
569 | text: "Zoom Out",
570 | title: "Zoom Out (Ctrl -)",
571 | },
572 | reset: {
573 | class: "reset-zoom-btn",
574 | icon: '',
575 | text: "Reset Zoom",
576 | title: "Reset Zoom",
577 | },
578 | center: {
579 | class: "center-view-btn",
580 | icon: '',
581 | text: "Center View",
582 | title: "Center View",
583 | },
584 | coords: {
585 | class: "coord-copy-btn",
586 | icon: "📍",
587 | text: "Copy Center",
588 | title: "Copy current center coordinates",
589 | },
590 | };
591 | return definitions[key];
592 | }
593 |
594 | function setFieldValue(field, value) {
595 | if (field.type === "checkbox") {
596 | field.checked = Boolean(value);
597 | return;
598 | }
599 | if (field.type === "color") {
600 | field.value = value || "#000000";
601 | return;
602 | }
603 | field.value = value ?? "";
604 | }
605 |
606 | function readFieldValue(field) {
607 | if (field.type === "checkbox") {
608 | return field.checked;
609 | }
610 | if (field.type === "color") {
611 | return field.value === field.dataset.originalValue ? "" : field.value;
612 | }
613 | return field.value;
614 | }
615 |
616 | function syncFields() {
617 | fieldElements.forEach((field) => {
618 | const key = field.dataset.setting;
619 | if (key in state) {
620 | setFieldValue(field, state[key]);
621 | }
622 | });
623 | }
624 |
625 | function buildWrapperClasses() {
626 | const controlsConfig = parseControlsConfig(Boolean(state.show_coords));
627 | const classes = [
628 | "svg-viewer-wrapper",
629 | `controls-position-${controlsConfig.position}`,
630 | `controls-mode-${controlsConfig.mode}`,
631 | `pan-mode-${normalizePan(state.pan_mode)}`,
632 | `zoom-mode-${normalizeZoom(state.zoom_mode)}`,
633 | ];
634 | controlsConfig.styles.forEach((style) =>
635 | classes.push(`controls-style-${style}`)
636 | );
637 | if (state.className) classes.push(state.className);
638 | return classes.join(" ");
639 | }
640 |
641 | function buildMainClasses(controlsConfig) {
642 | const classes = [
643 | "svg-viewer-main",
644 | `controls-position-${controlsConfig.position}`,
645 | `controls-align-${controlsConfig.alignment}`,
646 | ];
647 | controlsConfig.styles.forEach((style) =>
648 | classes.push(`controls-style-${style}`)
649 | );
650 | return classes.join(" ");
651 | }
652 |
653 | function buildWrapperStyle() {
654 | const declarations = ["width: 100%", "max-width: 100%", "min-width: 0"];
655 | if (isHexColor(state.button_fill)) {
656 | declarations.push(`--svg-viewer-button-fill: ${state.button_fill}`);
657 | const hover = adjustBrightness(state.button_fill, -12);
658 | if (hover) declarations.push(`--svg-viewer-button-hover: ${hover}`);
659 | }
660 | const border = state.button_border || state.button_fill;
661 | if (isHexColor(border)) {
662 | declarations.push(`--svg-viewer-button-border: ${border}`);
663 | }
664 | if (isHexColor(state.button_foreground)) {
665 | declarations.push(`--svg-viewer-button-text: ${state.button_foreground}`);
666 | }
667 | return declarations.join("; ");
668 | }
669 |
670 | function ensureViewerScript(callback) {
671 | if (window.SVGViewer) {
672 | callback();
673 | return;
674 | }
675 | const interval = setInterval(() => {
676 | if (window.SVGViewer) {
677 | clearInterval(interval);
678 | callback();
679 | }
680 | }, 50);
681 | setTimeout(() => clearInterval(interval), 3000);
682 | }
683 |
684 | function clampZoom(value) {
685 | const numeric = Number(value);
686 | if (Number.isFinite(numeric)) {
687 | return Math.max(1, Math.min(3200, Math.round(numeric)));
688 | }
689 | return 100;
690 | }
691 |
692 | function parseMaybeFloat(value) {
693 | const numeric = Number(value);
694 | return Number.isFinite(numeric) ? numeric : null;
695 | }
696 |
697 | function normalizePan(value) {
698 | return value && value.toString().toLowerCase() === "drag"
699 | ? "drag"
700 | : "scroll";
701 | }
702 |
703 | function normalizeZoom(value) {
704 | const normalized = (value || "")
705 | .toString()
706 | .toLowerCase()
707 | .replace(/[\s-]+/g, "_");
708 | if (normalized === "click" || normalized === "scroll") return normalized;
709 | return "super_scroll";
710 | }
711 |
712 | function escapeQuotes(value) {
713 | return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
714 | }
715 |
716 | function escapeHtml(value) {
717 | return value
718 | .replace(/&/g, "&")
719 | .replace(//g, ">")
721 | .replace(/"/g, """)
722 | .replace(/'/g, "'");
723 | }
724 |
725 | function isHexColor(value) {
726 | return /^#([0-9a-f]{3}){1,2}$/i.test(value || "");
727 | }
728 |
729 | function adjustBrightness(hexColor, percentage) {
730 | if (!isHexColor(hexColor)) return null;
731 | let hex = hexColor.replace("#", "");
732 | if (hex.length === 3)
733 | hex = hex
734 | .split("")
735 | .map((c) => c + c)
736 | .join("");
737 | const amount = Math.round(2.55 * percentage);
738 | const [r, g, b] = hex.match(/.{2}/g).map((component) => {
739 | return Math.max(0, Math.min(255, parseInt(component, 16) + amount));
740 | });
741 | return `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`;
742 | }
743 |
744 | async function copyText(text) {
745 | try {
746 | if (navigator.clipboard && navigator.clipboard.writeText) {
747 | await navigator.clipboard.writeText(text);
748 | } else {
749 | const textarea = document.createElement("textarea");
750 | textarea.value = text;
751 | textarea.style.position = "fixed";
752 | textarea.style.top = "-9999px";
753 | document.body.appendChild(textarea);
754 | textarea.focus();
755 | textarea.select();
756 | document.execCommand("copy");
757 | document.body.removeChild(textarea);
758 | }
759 | } catch (error) {
760 | console.error("[SVG Viewer Preview] copy failed", error);
761 | flashStatus("Copy failed. Select and copy the text manually.", "error");
762 | }
763 | }
764 |
765 | function openDialog(dialog) {
766 | if (!dialog) return;
767 | if (typeof dialog.showModal === "function") {
768 | dialog.showModal();
769 | } else {
770 | dialog.setAttribute("open", "");
771 | }
772 | }
773 |
774 | function closeDialog(dialog) {
775 | if (!dialog) return;
776 | if (typeof dialog.close === "function" && dialog.open) {
777 | dialog.close();
778 | } else {
779 | dialog.removeAttribute("open");
780 | }
781 | }
782 |
783 | let statusTimeout = null;
784 | function flashStatus(message, tone = "info") {
785 | if (!statusEl) return;
786 | statusEl.textContent = message;
787 | statusEl.dataset.tone = tone;
788 | clearTimeout(statusTimeout);
789 | statusTimeout = setTimeout(() => {
790 | statusEl.textContent = "";
791 | }, 3500);
792 | }
793 | })();
794 |
--------------------------------------------------------------------------------
/assets/svg-viewer/js/svg-viewer.js:
--------------------------------------------------------------------------------
1 | /**
2 | * SVG Viewer Plugin
3 | * Provides zoom, pan, and centering functionality for embedded SVGs
4 | */
5 |
6 | class SVGViewer {
7 | constructor(options) {
8 | this.viewerId = options.viewerId;
9 | this.svgUrl = options.svgUrl;
10 |
11 | // Configuration (with sensible defaults)
12 | this.ZOOM_STEP = options.zoomStep || 0.1;
13 | this.MIN_ZOOM =
14 | typeof options.minZoom === "number" ? options.minZoom : null;
15 | this.MAX_ZOOM = options.maxZoom || 8;
16 | this.initialZoom = options.initialZoom || 1;
17 |
18 | const parsedCenterX = Number(options.centerX);
19 | const parsedCenterY = Number(options.centerY);
20 | this.manualCenter =
21 | Number.isFinite(parsedCenterX) && Number.isFinite(parsedCenterY)
22 | ? { x: parsedCenterX, y: parsedCenterY }
23 | : null;
24 | this.showCoordinates = Boolean(options.showCoordinates);
25 | this.panMode = SVGViewer.normalizePanMode(options.panMode);
26 | this.zoomMode = SVGViewer.normalizeZoomMode(options.zoomMode);
27 | if (this.zoomMode === "scroll" && this.panMode === "scroll") {
28 | this.panMode = "drag";
29 | }
30 |
31 | // State
32 | this.currentZoom = this.initialZoom;
33 | this.svgElement = null;
34 | this.isLoading = false;
35 | this.baseDimensions = null;
36 | this.baseOrigin = { x: 0, y: 0 };
37 | this.baseCssDimensions = null;
38 | this.unitsPerCss = { x: 1, y: 1 };
39 | this.dragState = {
40 | isActive: false,
41 | pointerId: null,
42 | startX: 0,
43 | startY: 0,
44 | scrollLeft: 0,
45 | scrollTop: 0,
46 | lastClientX: 0,
47 | lastClientY: 0,
48 | inputType: null,
49 | prevScrollBehavior: "",
50 | };
51 | this.boundPointerDown = null;
52 | this.boundPointerMove = null;
53 | this.boundPointerUp = null;
54 | this.boundClickHandler = null;
55 | this.boundWheelHandler = null;
56 | this.dragListenersAttached = false;
57 | this.wheelDeltaBuffer = 0;
58 | this.wheelAnimationFrame = null;
59 | this.wheelFocusPoint = null;
60 | this.zoomAnimationFrame = null;
61 | this.pointerEventsSupported =
62 | typeof window !== "undefined" && window.PointerEvent;
63 | this.boundMouseDown = null;
64 | this.boundMouseMove = null;
65 | this.boundMouseUp = null;
66 | this.boundTouchStart = null;
67 | this.boundTouchMove = null;
68 | this.boundTouchEnd = null;
69 |
70 | // DOM Elements
71 | this.wrapper = document.getElementById(this.viewerId);
72 | this.container = this.wrapper.querySelector(
73 | '[data-viewer="' + this.viewerId + '"].svg-container'
74 | );
75 | this.viewport = this.wrapper.querySelector(
76 | '[data-viewer="' + this.viewerId + '"].svg-viewport'
77 | );
78 | this.zoomPercentageEl = this.wrapper.querySelector(
79 | '[data-viewer="' + this.viewerId + '"].zoom-percentage'
80 | );
81 | this.coordOutputEl = this.wrapper.querySelector(
82 | '[data-viewer="' + this.viewerId + '"].coord-output'
83 | );
84 | this.zoomInButtons = this.wrapper
85 | ? Array.from(
86 | this.wrapper.querySelectorAll(
87 | '[data-viewer="' + this.viewerId + '"].zoom-in-btn'
88 | )
89 | )
90 | : [];
91 | this.zoomOutButtons = this.wrapper
92 | ? Array.from(
93 | this.wrapper.querySelectorAll(
94 | '[data-viewer="' + this.viewerId + '"].zoom-out-btn'
95 | )
96 | )
97 | : [];
98 | this.zoomSliderEls = this.wrapper
99 | ? Array.from(
100 | this.wrapper.querySelectorAll(
101 | '[data-viewer="' + this.viewerId + '"].zoom-slider'
102 | )
103 | )
104 | : [];
105 | this.cleanupHandlers = [];
106 | this.boundKeydownHandler = null;
107 |
108 | this.init();
109 | }
110 |
111 | static normalizePanMode(value) {
112 | const raw =
113 | typeof value === "string" ? value.trim().toLowerCase() : String("");
114 | return raw === "drag" ? "drag" : "scroll";
115 | }
116 |
117 | static normalizeZoomMode(value) {
118 | const raw =
119 | typeof value === "string" ? value.trim().toLowerCase() : String("");
120 | const normalized = raw.replace(/[\s-]+/g, "_");
121 | if (normalized === "click") {
122 | return "click";
123 | }
124 | if (normalized === "scroll") {
125 | return "scroll";
126 | }
127 | return "super_scroll";
128 | }
129 |
130 | init() {
131 | this.setupEventListeners();
132 | this.updateZoomDisplay();
133 | this.updateViewport();
134 | this.loadSVG();
135 | }
136 |
137 | registerEvent(target, type, handler, options) {
138 | if (!target || typeof target.addEventListener !== "function") {
139 | return;
140 | }
141 | const listenerOptions = typeof options === "undefined" ? false : options;
142 | target.addEventListener(type, handler, listenerOptions);
143 | if (!Array.isArray(this.cleanupHandlers)) {
144 | this.cleanupHandlers = [];
145 | }
146 | this.cleanupHandlers.push(() => {
147 | if (!target || typeof target.removeEventListener !== "function") {
148 | return;
149 | }
150 | try {
151 | target.removeEventListener(type, handler, listenerOptions);
152 | } catch (err) {
153 | // Ignore listener removal errors
154 | }
155 | });
156 | }
157 |
158 | setupEventListeners() {
159 | if (this.zoomInButtons && this.zoomInButtons.length) {
160 | this.zoomInButtons.forEach((btn) => {
161 | const handler = () => this.zoomIn();
162 | this.registerEvent(btn, "click", handler);
163 | });
164 | }
165 |
166 | if (this.zoomOutButtons && this.zoomOutButtons.length) {
167 | this.zoomOutButtons.forEach((btn) => {
168 | const handler = () => this.zoomOut();
169 | this.registerEvent(btn, "click", handler);
170 | });
171 | }
172 |
173 | this.wrapper
174 | .querySelectorAll('[data-viewer="' + this.viewerId + '"].reset-zoom-btn')
175 | .forEach((btn) => {
176 | const handler = () => this.resetZoom();
177 | this.registerEvent(btn, "click", handler);
178 | });
179 |
180 | this.wrapper
181 | .querySelectorAll('[data-viewer="' + this.viewerId + '"].center-view-btn')
182 | .forEach((btn) => {
183 | const handler = () => this.centerView();
184 | this.registerEvent(btn, "click", handler);
185 | });
186 |
187 | if (this.showCoordinates) {
188 | this.wrapper
189 | .querySelectorAll(
190 | '[data-viewer="' + this.viewerId + '"].coord-copy-btn'
191 | )
192 | .forEach((btn) => {
193 | const handler = () => this.copyCenterCoordinates();
194 | this.registerEvent(btn, "click", handler);
195 | });
196 | }
197 |
198 | this.boundKeydownHandler =
199 | this.boundKeydownHandler || ((e) => this.handleKeyboard(e));
200 | this.registerEvent(document, "keydown", this.boundKeydownHandler);
201 |
202 | if (this.container) {
203 | this.boundWheelHandler =
204 | this.boundWheelHandler || ((event) => this.handleMouseWheel(event));
205 | const wheelOptions = { passive: false };
206 | this.registerEvent(this.container, "wheel", this.boundWheelHandler, wheelOptions);
207 | if (this.zoomMode === "click") {
208 | this.boundClickHandler =
209 | this.boundClickHandler || ((event) => this.handleContainerClick(event));
210 | this.registerEvent(this.container, "click", this.boundClickHandler);
211 | }
212 | }
213 |
214 | if (this.panMode === "drag") {
215 | this.enableDragPan();
216 | }
217 |
218 | if (this.zoomSliderEls && this.zoomSliderEls.length) {
219 | this.zoomSliderEls.forEach((slider) => {
220 | const handler = (event) => {
221 | const percent = parseFloat(event.target.value);
222 | if (!Number.isFinite(percent)) {
223 | return;
224 | }
225 | this.setZoom(percent / 100);
226 | };
227 | this.registerEvent(slider, "input", handler);
228 | });
229 | }
230 | }
231 |
232 | async loadSVG() {
233 | if (this.isLoading) return;
234 | this.isLoading = true;
235 |
236 | try {
237 | console.debug("[SVGViewer]", this.viewerId, "fetching", this.svgUrl);
238 | const response = await fetch(this.svgUrl);
239 | if (!response.ok)
240 | throw new Error(`Failed to load SVG: ${response.status}`);
241 |
242 | const svgText = await response.text();
243 | this.viewport.innerHTML = svgText;
244 | this.svgElement = this.viewport.querySelector("svg");
245 |
246 | if (this.svgElement) {
247 | console.debug("[SVGViewer]", this.viewerId, "SVG loaded");
248 | this.prepareSvgElement();
249 | this.captureBaseDimensions();
250 | this.currentZoom = this.initialZoom;
251 | this.updateViewport({ immediate: true });
252 | this.centerView();
253 | }
254 | } catch (error) {
255 | console.error("SVG Viewer Error:", error);
256 | this.viewport.innerHTML =
257 | 'Error loading SVG. Check the file path and ensure CORS is configured if needed.';
258 | }
259 |
260 | this.isLoading = false;
261 | }
262 |
263 | setZoom(newZoom, options = {}) {
264 | if (options.animate && !options.__animationStep) {
265 | this.startZoomAnimation(newZoom, options);
266 | return;
267 | }
268 |
269 | if (this.zoomAnimationFrame && !options.__animationStep) {
270 | window.cancelAnimationFrame(this.zoomAnimationFrame);
271 | this.zoomAnimationFrame = null;
272 | }
273 |
274 | if (!this.container || !this.viewport) {
275 | const minZoomFallback =
276 | Number.isFinite(this.MIN_ZOOM) && this.MIN_ZOOM > 0 ? this.MIN_ZOOM : 0;
277 | const maxZoomFallback =
278 | Number.isFinite(this.MAX_ZOOM) && this.MAX_ZOOM > 0
279 | ? this.MAX_ZOOM
280 | : newZoom;
281 | this.currentZoom = Math.max(
282 | minZoomFallback,
283 | Math.min(maxZoomFallback, newZoom)
284 | );
285 | this.updateZoomDisplay();
286 | return;
287 | }
288 |
289 | const prevZoom = this.currentZoom || 1;
290 | const baseWidth = this.baseDimensions ? this.baseDimensions.width : 1;
291 | const baseHeight = this.baseDimensions ? this.baseDimensions.height : 1;
292 | const prevTransform = this.container?.style.transform;
293 | if (this.container) {
294 | this.container.style.transform = "translate3d(0,0,0)";
295 | }
296 |
297 | const resolvedMinZoom = this.resolveMinZoom();
298 | const effectiveMinZoom =
299 | Number.isFinite(resolvedMinZoom) && resolvedMinZoom > 0
300 | ? resolvedMinZoom
301 | : 0;
302 | const effectiveMaxZoom =
303 | Number.isFinite(this.MAX_ZOOM) && this.MAX_ZOOM > 0
304 | ? this.MAX_ZOOM
305 | : Math.max(newZoom, 0);
306 |
307 | const focusData =
308 | options.__focusData && typeof options.__focusData === "object"
309 | ? options.__focusData
310 | : this._computeFocusData(prevZoom, options);
311 | const focusBaseX = focusData.focusBaseX;
312 | const focusBaseY = focusData.focusBaseY;
313 | const focusOffsetX = focusData.focusOffsetX;
314 | const focusOffsetY = focusData.focusOffsetY;
315 |
316 | this.currentZoom = Math.max(
317 | effectiveMinZoom,
318 | Math.min(effectiveMaxZoom, newZoom)
319 | );
320 |
321 | this.updateViewport(options);
322 | this.updateZoomDisplay();
323 |
324 | const newScrollWidth = this.container.scrollWidth;
325 | const newScrollHeight = this.container.scrollHeight;
326 |
327 | if (!options.center && newScrollWidth && newScrollHeight) {
328 | const focusCssXAfter =
329 | ((focusBaseX - this.baseOrigin.x) / (this.unitsPerCss.x || 1)) *
330 | this.currentZoom;
331 | const focusCssYAfter =
332 | ((focusBaseY - this.baseOrigin.y) / (this.unitsPerCss.y || 1)) *
333 | this.currentZoom;
334 |
335 | let targetLeft;
336 | if (typeof focusOffsetX === "number") {
337 | targetLeft = focusCssXAfter - focusOffsetX;
338 | } else {
339 | targetLeft = focusCssXAfter - this.container.clientWidth / 2;
340 | }
341 |
342 | let targetTop;
343 | if (typeof focusOffsetY === "number") {
344 | targetTop = focusCssYAfter - focusOffsetY;
345 | } else {
346 | targetTop = focusCssYAfter - this.container.clientHeight / 2;
347 | }
348 |
349 | this._debugLastZoom = {
350 | focusBaseX,
351 | focusBaseY,
352 | focusCssXAfter,
353 | focusCssYAfter,
354 | targetLeft,
355 | targetTop,
356 | scrollWidth: newScrollWidth,
357 | scrollHeight: newScrollHeight,
358 | };
359 |
360 | const maxLeft = Math.max(0, newScrollWidth - this.container.clientWidth);
361 | const maxTop = Math.max(0, newScrollHeight - this.container.clientHeight);
362 |
363 | const clampedLeft = Math.min(maxLeft, Math.max(0, targetLeft));
364 | const clampedTop = Math.min(maxTop, Math.max(0, targetTop));
365 |
366 | const previousBehavior = this.container.style.scrollBehavior;
367 | this.container.style.scrollBehavior = "auto";
368 | this.container.scrollLeft = clampedLeft;
369 | this.container.scrollTop = clampedTop;
370 | this.container.style.scrollBehavior = previousBehavior;
371 | }
372 |
373 | if (options.center) {
374 | const centerPoint =
375 | typeof options.focusX === "number" && typeof options.focusY === "number"
376 | ? { x: options.focusX, y: options.focusY }
377 | : this.getCenterPoint();
378 | this.centerView({ focusX: centerPoint.x, focusY: centerPoint.y });
379 | }
380 |
381 | if (this.container) {
382 | this.container.style.transform = prevTransform || "";
383 | }
384 | }
385 |
386 | _computeFocusData(prevZoom, options = {}) {
387 | const data = {
388 | focusBaseX: 0,
389 | focusBaseY: 0,
390 | focusOffsetX: null,
391 | focusOffsetY: null,
392 | };
393 |
394 | if (!this.container) {
395 | return data;
396 | }
397 |
398 | if (
399 | typeof options.focusX === "number" &&
400 | typeof options.focusY === "number"
401 | ) {
402 | data.focusBaseX = options.focusX;
403 | data.focusBaseY = options.focusY;
404 | } else {
405 | const visibleCenterX =
406 | this.container.scrollLeft + this.container.clientWidth / 2;
407 | const visibleCenterY =
408 | this.container.scrollTop + this.container.clientHeight / 2;
409 |
410 | const cssBaseX = visibleCenterX / prevZoom;
411 | const cssBaseY = visibleCenterY / prevZoom;
412 |
413 | data.focusBaseX =
414 | this.baseOrigin.x + cssBaseX * (this.unitsPerCss.x || 1);
415 | data.focusBaseY =
416 | this.baseOrigin.y + cssBaseY * (this.unitsPerCss.y || 1);
417 | }
418 |
419 | if (typeof options.focusOffsetX === "number") {
420 | data.focusOffsetX = options.focusOffsetX;
421 | }
422 | if (typeof options.focusOffsetY === "number") {
423 | data.focusOffsetY = options.focusOffsetY;
424 | }
425 |
426 | return data;
427 | }
428 |
429 | updateViewport({ immediate = false } = {}) {
430 | if (!this.baseCssDimensions || !this.viewport || !this.svgElement) return;
431 |
432 | const width = this.baseCssDimensions.width * this.currentZoom;
433 | const height = this.baseCssDimensions.height * this.currentZoom;
434 |
435 | this.viewport.style.width = `${width}px`;
436 | this.viewport.style.height = `${height}px`;
437 | this.svgElement.style.width = `${width}px`;
438 | this.svgElement.style.height = `${height}px`;
439 | this.viewport.style.transform = "none";
440 | this.svgElement.style.transform = "none";
441 |
442 | if (immediate) {
443 | this.viewport.getBoundingClientRect(); // force layout
444 | }
445 | }
446 |
447 | updateZoomDisplay() {
448 | if (this.zoomPercentageEl) {
449 | this.zoomPercentageEl.textContent = Math.round(this.currentZoom * 100);
450 | }
451 |
452 | if (this.zoomSliderEls && this.zoomSliderEls.length) {
453 | const sliderValue = Math.round(this.currentZoom * 100);
454 | this.zoomSliderEls.forEach((slider) => {
455 | slider.value = String(sliderValue);
456 | slider.setAttribute("aria-valuenow", String(sliderValue));
457 | });
458 | }
459 |
460 | this.updateZoomButtonState();
461 | }
462 |
463 | zoomIn() {
464 | const targetZoom = this.computeZoomTarget("in");
465 | this.setZoom(targetZoom, { animate: true });
466 | }
467 |
468 | zoomOut() {
469 | const targetZoom = this.computeZoomTarget("out");
470 | this.setZoom(targetZoom, { animate: true });
471 | }
472 |
473 | resetZoom() {
474 | this.setZoom(this.initialZoom || 1, { center: true, animate: true });
475 | }
476 |
477 | startZoomAnimation(targetZoom, options = {}) {
478 | if (!this.container || !this.viewport) {
479 | this.setZoom(targetZoom, { ...options, animate: false });
480 | return;
481 | }
482 |
483 | if (this.zoomAnimationFrame) {
484 | window.cancelAnimationFrame(this.zoomAnimationFrame);
485 | this.zoomAnimationFrame = null;
486 | }
487 |
488 | const startZoom = this.currentZoom || 1;
489 | const focusData = this._computeFocusData(startZoom, options);
490 | const duration =
491 | typeof options.zoomAnimationDuration === "number"
492 | ? Math.max(0, options.zoomAnimationDuration)
493 | : 160;
494 | const easing =
495 | typeof options.zoomAnimationEasing === "function"
496 | ? options.zoomAnimationEasing
497 | : this._easeOutCubic;
498 |
499 | const frameOptions = {
500 | ...options,
501 | animate: false,
502 | __animationStep: true,
503 | __focusData: focusData,
504 | };
505 | delete frameOptions.center;
506 |
507 | const startTimeRef = { value: null };
508 |
509 | const animateFrame = (timestamp) => {
510 | if (startTimeRef.value === null) {
511 | startTimeRef.value = timestamp;
512 | }
513 | const elapsed = timestamp - startTimeRef.value;
514 | const progress =
515 | duration === 0 ? 1 : Math.min(elapsed / duration, 1);
516 | const easedProgress = easing(progress);
517 | const intermediateZoom =
518 | startZoom + (targetZoom - startZoom) * easedProgress;
519 |
520 | this.setZoom(intermediateZoom, frameOptions);
521 |
522 | if (progress < 1) {
523 | this.zoomAnimationFrame =
524 | window.requestAnimationFrame(animateFrame);
525 | } else {
526 | this.zoomAnimationFrame = null;
527 | this.setZoom(targetZoom, {
528 | ...options,
529 | animate: false,
530 | __animationStep: true,
531 | __focusData: focusData,
532 | });
533 | }
534 | };
535 |
536 | this.zoomAnimationFrame = window.requestAnimationFrame(animateFrame);
537 | }
538 |
539 | _easeOutCubic(t) {
540 | return 1 - Math.pow(1 - t, 3);
541 | }
542 |
543 | centerView(input = {}) {
544 | if (!this.container || !this.baseDimensions || !this.baseCssDimensions)
545 | return;
546 |
547 | let focusX;
548 | let focusY;
549 |
550 | if (typeof input.focusX === "number" && typeof input.focusY === "number") {
551 | focusX = input.focusX;
552 | focusY = input.focusY;
553 | } else if (typeof input.x === "number" && typeof input.y === "number") {
554 | focusX = input.x;
555 | focusY = input.y;
556 | } else {
557 | const center = this.getCenterPoint();
558 | focusX = center.x;
559 | focusY = center.y;
560 | }
561 |
562 | const cssCenterX =
563 | ((focusX - this.baseOrigin.x) / (this.unitsPerCss.x || 1)) *
564 | this.currentZoom;
565 | const cssCenterY =
566 | ((focusY - this.baseOrigin.y) / (this.unitsPerCss.y || 1)) *
567 | this.currentZoom;
568 |
569 | const targetLeft = cssCenterX - this.container.clientWidth / 2;
570 | const targetTop = cssCenterY - this.container.clientHeight / 2;
571 |
572 | const maxLeft = Math.max(
573 | 0,
574 | this.container.scrollWidth - this.container.clientWidth
575 | );
576 | const maxTop = Math.max(
577 | 0,
578 | this.container.scrollHeight - this.container.clientHeight
579 | );
580 |
581 | const clampedLeft = Math.min(maxLeft, Math.max(0, targetLeft));
582 | const clampedTop = Math.min(maxTop, Math.max(0, targetTop));
583 |
584 | const previousBehavior = this.container.style.scrollBehavior;
585 | this.container.style.scrollBehavior = "auto";
586 | this.container.scrollLeft = clampedLeft;
587 | this.container.scrollTop = clampedTop;
588 | this.container.style.scrollBehavior = previousBehavior;
589 | }
590 |
591 | prepareSvgElement() {
592 | if (!this.svgElement) return;
593 | this.svgElement.style.maxWidth = "none";
594 | this.svgElement.style.maxHeight = "none";
595 | this.svgElement.style.display = "block";
596 | }
597 |
598 | captureBaseDimensions() {
599 | if (!this.svgElement || !this.viewport) return;
600 |
601 | const rect = this.svgElement.getBoundingClientRect();
602 | let cssWidth = rect.width || this.svgElement.clientWidth || 1;
603 | let cssHeight = rect.height || this.svgElement.clientHeight || 1;
604 |
605 | const viewBox =
606 | this.svgElement.viewBox && this.svgElement.viewBox.baseVal
607 | ? this.svgElement.viewBox.baseVal
608 | : null;
609 |
610 | if (viewBox) {
611 | this.baseDimensions = {
612 | width: viewBox.width || 1,
613 | height: viewBox.height || 1,
614 | };
615 | this.baseOrigin = { x: viewBox.x || 0, y: viewBox.y || 0 };
616 | } else {
617 | this.baseDimensions = { width: cssWidth, height: cssHeight };
618 | this.baseOrigin = { x: 0, y: 0 };
619 | }
620 |
621 | if (cssWidth <= 1 || cssHeight <= 1) {
622 | if (viewBox && viewBox.width && viewBox.height) {
623 | cssWidth = viewBox.width;
624 | cssHeight = viewBox.height;
625 | } else {
626 | const fallbackWidth = this.container ? this.container.clientWidth : 0;
627 | const fallbackHeight = this.container ? this.container.clientHeight : 0;
628 | cssWidth = fallbackWidth || this.baseDimensions.width || 1;
629 | cssHeight = fallbackHeight || this.baseDimensions.height || 1;
630 | }
631 | }
632 |
633 | this.baseCssDimensions = { width: cssWidth, height: cssHeight };
634 | this.unitsPerCss = {
635 | x: this.baseDimensions.width / cssWidth,
636 | y: this.baseDimensions.height / cssHeight,
637 | };
638 |
639 | this.viewport.style.width = `${cssWidth}px`;
640 | this.viewport.style.height = `${cssHeight}px`;
641 | this.svgElement.style.width = `${cssWidth}px`;
642 | this.svgElement.style.height = `${cssHeight}px`;
643 | }
644 |
645 | handleKeyboard(e) {
646 | // Only handle shortcuts if focused on this viewer or its container
647 | const isViewerFocused =
648 | this.wrapper.contains(document.activeElement) ||
649 | e.target === document ||
650 | e.target === document.body;
651 |
652 | if (!isViewerFocused) return;
653 |
654 | // Ctrl/Cmd + Plus
655 | if ((e.ctrlKey || e.metaKey) && (e.key === "+" || e.key === "=")) {
656 | e.preventDefault();
657 | this.zoomIn();
658 | }
659 | // Ctrl/Cmd + Minus
660 | if ((e.ctrlKey || e.metaKey) && e.key === "-") {
661 | e.preventDefault();
662 | this.zoomOut();
663 | }
664 | // Ctrl/Cmd + 0 to reset
665 | if ((e.ctrlKey || e.metaKey) && e.key === "0") {
666 | e.preventDefault();
667 | this.resetZoom();
668 | }
669 | }
670 |
671 | handleMouseWheel(event) {
672 | if (!this.container) {
673 | return;
674 | }
675 |
676 | if (this.dragState && this.dragState.isActive && this.panMode === "drag") {
677 | return;
678 | }
679 |
680 | const initialScrollLeft = this.container.scrollLeft;
681 | const hadHorizontalDelta =
682 | typeof event.deltaX === "number" && event.deltaX !== 0;
683 | const absDeltaX = Math.abs(event.deltaX || 0);
684 | const absDeltaY = Math.abs(event.deltaY || 0);
685 | const verticalDominant =
686 | absDeltaX === 0 ? absDeltaY > 0 : absDeltaY >= absDeltaX * 1.5;
687 | const meetsThreshold = absDeltaY >= 4;
688 | const shouldZoom = verticalDominant && meetsThreshold;
689 | const panRequiresDrag = this.panMode === "drag";
690 |
691 | if (this.zoomMode === "scroll") {
692 | if (!shouldZoom) {
693 | if (panRequiresDrag) {
694 | event.preventDefault();
695 | if (hadHorizontalDelta) {
696 | this.container.scrollLeft = initialScrollLeft;
697 | }
698 | }
699 | return;
700 | }
701 | event.preventDefault();
702 | this.performWheelZoom(event);
703 | if (hadHorizontalDelta) {
704 | this.container.scrollLeft = initialScrollLeft;
705 | }
706 | return;
707 | }
708 |
709 | const hasModifier = event.ctrlKey || event.metaKey;
710 |
711 | if (this.zoomMode === "super_scroll") {
712 | if (!hasModifier) {
713 | if (panRequiresDrag) {
714 | event.preventDefault();
715 | if (hadHorizontalDelta) {
716 | this.container.scrollLeft = initialScrollLeft;
717 | }
718 | }
719 | return;
720 | }
721 | if (!shouldZoom) {
722 | if (panRequiresDrag) {
723 | event.preventDefault();
724 | if (hadHorizontalDelta) {
725 | this.container.scrollLeft = initialScrollLeft;
726 | }
727 | }
728 | return;
729 | }
730 | event.preventDefault();
731 | this.performWheelZoom(event);
732 | if (hadHorizontalDelta) {
733 | this.container.scrollLeft = initialScrollLeft;
734 | }
735 | return;
736 | }
737 |
738 | if (this.zoomMode === "click") {
739 | if (!hasModifier) {
740 | if (panRequiresDrag) {
741 | event.preventDefault();
742 | if (hadHorizontalDelta) {
743 | this.container.scrollLeft = initialScrollLeft;
744 | }
745 | }
746 | return;
747 | }
748 | if (!shouldZoom) {
749 | if (panRequiresDrag) {
750 | event.preventDefault();
751 | if (hadHorizontalDelta) {
752 | this.container.scrollLeft = initialScrollLeft;
753 | }
754 | }
755 | return;
756 | }
757 | event.preventDefault();
758 | this.performWheelZoom(event);
759 | if (hadHorizontalDelta) {
760 | this.container.scrollLeft = initialScrollLeft;
761 | }
762 | }
763 | }
764 |
765 | getFocusPointFromEvent(event) {
766 | if (!this.container) {
767 | return null;
768 | }
769 |
770 | let clientX = null;
771 | let clientY = null;
772 |
773 | if (typeof event.clientX === "number" && typeof event.clientY === "number") {
774 | clientX = event.clientX;
775 | clientY = event.clientY;
776 | } else if (event.touches && event.touches.length) {
777 | clientX = event.touches[0].clientX;
778 | clientY = event.touches[0].clientY;
779 | }
780 |
781 | if (clientX === null || clientY === null) {
782 | return null;
783 | }
784 |
785 | const rect = this.container.getBoundingClientRect();
786 | const cursorX = clientX - rect.left + this.container.scrollLeft;
787 | const cursorY = clientY - rect.top + this.container.scrollTop;
788 |
789 | const zoom = this.currentZoom || 1;
790 | const unitsX = this.unitsPerCss.x || 1;
791 | const unitsY = this.unitsPerCss.y || 1;
792 |
793 | const baseX = this.baseOrigin.x + (cursorX / zoom) * unitsX;
794 | const baseY = this.baseOrigin.y + (cursorY / zoom) * unitsY;
795 | const pointerOffsetX =
796 | typeof clientX === "number" && Number.isFinite(clientX)
797 | ? clientX - rect.left
798 | : null;
799 | const pointerOffsetY =
800 | typeof clientY === "number" && Number.isFinite(clientY)
801 | ? clientY - rect.top
802 | : null;
803 |
804 | return {
805 | baseX,
806 | baseY,
807 | pointerOffsetX,
808 | pointerOffsetY,
809 | };
810 | }
811 |
812 | performWheelZoom(event) {
813 | const normalizedDelta = this.normalizeWheelDelta(event);
814 | if (!normalizedDelta) {
815 | return;
816 | }
817 |
818 | const focusPoint = this.getFocusPointFromEvent(event);
819 | this.enqueueWheelDelta(normalizedDelta, focusPoint);
820 | }
821 |
822 | normalizeWheelDelta(event) {
823 | if (!event) {
824 | return 0;
825 | }
826 |
827 | let delta = Number(event.deltaY);
828 | if (!Number.isFinite(delta)) {
829 | return 0;
830 | }
831 |
832 | switch (event.deltaMode) {
833 | case 1: // lines
834 | delta *= 16;
835 | break;
836 | case 2: // pages
837 | delta *= this.getWheelPageDistance();
838 | break;
839 | default:
840 | break;
841 | }
842 |
843 | return delta;
844 | }
845 |
846 | enqueueWheelDelta(delta, focusPoint) {
847 | if (!Number.isFinite(delta) || delta === 0) {
848 | return;
849 | }
850 |
851 | this.wheelDeltaBuffer += delta;
852 |
853 | if (focusPoint) {
854 | this.wheelFocusPoint = focusPoint;
855 | }
856 |
857 | if (this.wheelAnimationFrame) {
858 | return;
859 | }
860 |
861 | this.wheelAnimationFrame = window.requestAnimationFrame(() =>
862 | this.flushWheelDelta()
863 | );
864 | }
865 |
866 | flushWheelDelta() {
867 | this.wheelAnimationFrame = null;
868 | const delta = this.wheelDeltaBuffer;
869 | this.wheelDeltaBuffer = 0;
870 |
871 | if (!Number.isFinite(delta) || delta === 0) {
872 | this.wheelFocusPoint = null;
873 | return;
874 | }
875 |
876 | const zoomRange = (this.MAX_ZOOM || 0) - (this.MIN_ZOOM || 0);
877 | if (!Number.isFinite(zoomRange) || zoomRange <= 0) {
878 | const direction = delta > 0 ? -1 : 1;
879 | this.setZoom(this.currentZoom + direction * this.ZOOM_STEP);
880 | this.wheelFocusPoint = null;
881 | return;
882 | }
883 |
884 | const pageDistance = this.getWheelPageDistance();
885 | if (!Number.isFinite(pageDistance) || pageDistance <= 0) {
886 | this.wheelFocusPoint = null;
887 | return;
888 | }
889 |
890 | const zoomDelta = (-delta / pageDistance) * zoomRange;
891 | if (!Number.isFinite(zoomDelta) || zoomDelta === 0) {
892 | this.wheelFocusPoint = null;
893 | return;
894 | }
895 |
896 | const targetZoom = this.currentZoom + zoomDelta;
897 | const focusPoint = this.wheelFocusPoint;
898 | this.wheelFocusPoint = null;
899 |
900 | const zoomOptions = {
901 | animate: true,
902 | };
903 |
904 | if (focusPoint) {
905 | zoomOptions.focusX = focusPoint.baseX;
906 | zoomOptions.focusY = focusPoint.baseY;
907 | if (typeof focusPoint.pointerOffsetX === "number") {
908 | zoomOptions.focusOffsetX = focusPoint.pointerOffsetX;
909 | }
910 | if (typeof focusPoint.pointerOffsetY === "number") {
911 | zoomOptions.focusOffsetY = focusPoint.pointerOffsetY;
912 | }
913 | }
914 |
915 | this.setZoom(targetZoom, zoomOptions);
916 | }
917 |
918 | getWheelPageDistance() {
919 | if (this.container && this.container.clientHeight) {
920 | return Math.max(200, this.container.clientHeight);
921 | }
922 | if (typeof window !== "undefined" && window.innerHeight) {
923 | return Math.max(200, window.innerHeight);
924 | }
925 | return 600;
926 | }
927 |
928 | enableDragPan() {
929 | if (!this.container || this.dragListenersAttached) {
930 | return;
931 | }
932 |
933 | this.boundPointerDown =
934 | this.boundPointerDown || ((event) => this.handlePointerDown(event));
935 | this.boundPointerMove =
936 | this.boundPointerMove || ((event) => this.handlePointerMove(event));
937 | this.boundPointerUp =
938 | this.boundPointerUp || ((event) => this.handlePointerUp(event));
939 | this.boundMouseDown =
940 | this.boundMouseDown || ((event) => this.handleMouseDown(event));
941 | this.boundMouseMove =
942 | this.boundMouseMove || ((event) => this.handleMouseMove(event));
943 | this.boundMouseUp =
944 | this.boundMouseUp || ((event) => this.handleMouseUp(event));
945 | this.boundTouchStart =
946 | this.boundTouchStart || ((event) => this.handleTouchStart(event));
947 | this.boundTouchMove =
948 | this.boundTouchMove || ((event) => this.handleTouchMove(event));
949 | this.boundTouchEnd =
950 | this.boundTouchEnd || ((event) => this.handleTouchEnd(event));
951 |
952 | this.registerEvent(this.container, "pointerdown", this.boundPointerDown);
953 | this.registerEvent(window, "pointermove", this.boundPointerMove);
954 | this.registerEvent(window, "pointerup", this.boundPointerUp);
955 | this.registerEvent(window, "pointercancel", this.boundPointerUp);
956 |
957 | this.registerEvent(this.container, "mousedown", this.boundMouseDown);
958 | this.registerEvent(window, "mousemove", this.boundMouseMove);
959 | this.registerEvent(window, "mouseup", this.boundMouseUp);
960 |
961 | const touchStartOptions = { passive: false };
962 | const touchMoveOptions = { passive: false };
963 | this.registerEvent(this.container, "touchstart", this.boundTouchStart, touchStartOptions);
964 | this.registerEvent(window, "touchmove", this.boundTouchMove, touchMoveOptions);
965 | this.registerEvent(window, "touchend", this.boundTouchEnd);
966 | this.registerEvent(window, "touchcancel", this.boundTouchEnd);
967 |
968 | if (this.container.style) {
969 | this.container.style.touchAction = this.pointerEventsSupported
970 | ? "pan-x pan-y"
971 | : "none";
972 | }
973 | this.dragListenersAttached = true;
974 | }
975 |
976 | handlePointerDown(event) {
977 | if (this.panMode !== "drag" || !this.container) {
978 | return;
979 | }
980 | if (!event.isPrimary) {
981 | return;
982 | }
983 | if (event.pointerType === "mouse" && event.button !== 0) {
984 | return;
985 | }
986 | if (
987 | this.zoomMode === "click" &&
988 | (event.metaKey || event.ctrlKey || event.altKey)
989 | ) {
990 | return;
991 | }
992 |
993 | event.preventDefault();
994 | this.beginDrag({
995 | clientX: event.clientX,
996 | clientY: event.clientY,
997 | pointerId:
998 | typeof event.pointerId === "number" ? event.pointerId : event.pointerId,
999 | inputType: event.pointerType || "pointer",
1000 | sourceEvent: event,
1001 | });
1002 | }
1003 |
1004 | handlePointerMove(event) {
1005 | if (
1006 | !this.dragState.isActive ||
1007 | !this.container ||
1008 | (this.dragState.pointerId !== null &&
1009 | typeof event.pointerId === "number" &&
1010 | event.pointerId !== this.dragState.pointerId)
1011 | ) {
1012 | return;
1013 | }
1014 |
1015 | event.preventDefault();
1016 | this.updateDrag({
1017 | clientX: event.clientX,
1018 | clientY: event.clientY,
1019 | });
1020 | }
1021 |
1022 | handlePointerUp(event) {
1023 | if (
1024 | !this.dragState.isActive ||
1025 | (this.dragState.pointerId !== null &&
1026 | typeof event.pointerId === "number" &&
1027 | event.pointerId !== this.dragState.pointerId)
1028 | ) {
1029 | return;
1030 | }
1031 |
1032 | this.endDrag({
1033 | pointerId:
1034 | typeof event.pointerId === "number" ? event.pointerId : null,
1035 | sourceEvent: event,
1036 | });
1037 | }
1038 |
1039 | handleMouseDown(event) {
1040 | if (this.panMode !== "drag" || !this.container) {
1041 | return;
1042 | }
1043 | if (this.pointerEventsSupported) {
1044 | return;
1045 | }
1046 | if (event.button !== 0) {
1047 | return;
1048 | }
1049 |
1050 | event.preventDefault();
1051 | this.beginDrag({
1052 | clientX: event.clientX,
1053 | clientY: event.clientY,
1054 | pointerId: "mouse",
1055 | inputType: "mouse",
1056 | });
1057 | }
1058 |
1059 | handleMouseMove(event) {
1060 | if (
1061 | !this.dragState.isActive ||
1062 | this.dragState.inputType !== "mouse" ||
1063 | !this.container
1064 | ) {
1065 | return;
1066 | }
1067 | if (this.pointerEventsSupported) {
1068 | return;
1069 | }
1070 |
1071 | event.preventDefault();
1072 | this.updateDrag({
1073 | clientX: event.clientX,
1074 | clientY: event.clientY,
1075 | });
1076 | }
1077 |
1078 | handleMouseUp() {
1079 | if (!this.dragState.isActive || this.dragState.inputType !== "mouse") {
1080 | return;
1081 | }
1082 | if (this.pointerEventsSupported) {
1083 | return;
1084 | }
1085 |
1086 | this.endDrag({ pointerId: "mouse" });
1087 | }
1088 |
1089 | handleTouchStart(event) {
1090 | if (this.panMode !== "drag" || !this.container) {
1091 | return;
1092 | }
1093 | if (this.pointerEventsSupported) {
1094 | return;
1095 | }
1096 | if (!event.touches || !event.touches.length) {
1097 | return;
1098 | }
1099 |
1100 | const touch = event.touches[0];
1101 | event.preventDefault();
1102 | this.beginDrag({
1103 | clientX: touch.clientX,
1104 | clientY: touch.clientY,
1105 | pointerId: touch.identifier,
1106 | inputType: "touch",
1107 | });
1108 | }
1109 |
1110 | handleTouchMove(event) {
1111 | if (
1112 | !this.dragState.isActive ||
1113 | this.dragState.inputType !== "touch" ||
1114 | !this.container
1115 | ) {
1116 | return;
1117 | }
1118 | if (this.pointerEventsSupported) {
1119 | return;
1120 | }
1121 |
1122 | const touch = this.getTrackedTouch(
1123 | event.touches,
1124 | this.dragState.pointerId
1125 | );
1126 | if (!touch) {
1127 | return;
1128 | }
1129 |
1130 | event.preventDefault();
1131 | this.updateDrag({
1132 | clientX: touch.clientX,
1133 | clientY: touch.clientY,
1134 | });
1135 | }
1136 |
1137 | handleTouchEnd(event) {
1138 | if (
1139 | !this.dragState.isActive ||
1140 | this.dragState.inputType !== "touch"
1141 | ) {
1142 | return;
1143 | }
1144 | if (this.pointerEventsSupported) {
1145 | return;
1146 | }
1147 |
1148 | const remainingTouch = this.getTrackedTouch(
1149 | event.touches,
1150 | this.dragState.pointerId
1151 | );
1152 | if (!remainingTouch) {
1153 | this.endDrag({ pointerId: this.dragState.pointerId });
1154 | }
1155 | }
1156 |
1157 | beginDrag({
1158 | clientX,
1159 | clientY,
1160 | pointerId = null,
1161 | inputType = null,
1162 | sourceEvent = null,
1163 | }) {
1164 | if (!this.container) {
1165 | return;
1166 | }
1167 |
1168 | if (this.dragState.isActive) {
1169 | this.endDrag();
1170 | }
1171 |
1172 | this.dragState.isActive = true;
1173 | this.dragState.pointerId = pointerId;
1174 | this.dragState.inputType = inputType || null;
1175 | this.dragState.startX = clientX;
1176 | this.dragState.startY = clientY;
1177 | this.dragState.scrollLeft = this.container.scrollLeft;
1178 | this.dragState.scrollTop = this.container.scrollTop;
1179 | this.dragState.lastClientX = clientX;
1180 | this.dragState.lastClientY = clientY;
1181 | this.dragState.prevScrollBehavior =
1182 | typeof this.container.style.scrollBehavior === "string"
1183 | ? this.container.style.scrollBehavior
1184 | : "";
1185 |
1186 | if (this.container && this.container.style) {
1187 | this.container.style.scrollBehavior = "auto";
1188 | }
1189 |
1190 | if (this.container.classList) {
1191 | this.container.classList.add("is-dragging");
1192 | }
1193 |
1194 | if (
1195 | sourceEvent &&
1196 | typeof sourceEvent.pointerId === "number" &&
1197 | typeof this.container.setPointerCapture === "function"
1198 | ) {
1199 | try {
1200 | this.container.setPointerCapture(sourceEvent.pointerId);
1201 | } catch (err) {
1202 | // Ignore pointer capture errors
1203 | }
1204 | }
1205 | }
1206 |
1207 | updateDrag({ clientX, clientY }) {
1208 | if (!this.dragState.isActive || !this.container) {
1209 | return;
1210 | }
1211 |
1212 | const deltaX = clientX - this.dragState.lastClientX;
1213 | const deltaY = clientY - this.dragState.lastClientY;
1214 |
1215 | if (deltaX) {
1216 | this.container.scrollLeft -= deltaX;
1217 | }
1218 | if (deltaY) {
1219 | this.container.scrollTop -= deltaY;
1220 | }
1221 |
1222 | this.dragState.lastClientX = clientX;
1223 | this.dragState.lastClientY = clientY;
1224 | this.dragState.scrollLeft = this.container.scrollLeft;
1225 | this.dragState.scrollTop = this.container.scrollTop;
1226 | }
1227 |
1228 | endDrag({ pointerId = null, sourceEvent = null } = {}) {
1229 | if (!this.dragState.isActive) {
1230 | return;
1231 | }
1232 |
1233 | if (
1234 | this.dragState.pointerId !== null &&
1235 | pointerId !== null &&
1236 | pointerId !== this.dragState.pointerId
1237 | ) {
1238 | return;
1239 | }
1240 |
1241 | const capturedPointer = this.dragState.pointerId;
1242 |
1243 | this.dragState.isActive = false;
1244 | this.dragState.pointerId = null;
1245 | this.dragState.inputType = null;
1246 | this.dragState.lastClientX = 0;
1247 | this.dragState.lastClientY = 0;
1248 | const previousScrollBehavior = this.dragState.prevScrollBehavior;
1249 | this.dragState.prevScrollBehavior = "";
1250 |
1251 | if (this.container && this.container.classList) {
1252 | this.container.classList.remove("is-dragging");
1253 | }
1254 |
1255 | if (
1256 | sourceEvent &&
1257 | typeof sourceEvent.pointerId === "number" &&
1258 | typeof this.container.releasePointerCapture === "function"
1259 | ) {
1260 | try {
1261 | this.container.releasePointerCapture(sourceEvent.pointerId);
1262 | } catch (err) {
1263 | // Ignore release errors
1264 | }
1265 | } else if (
1266 | typeof capturedPointer === "number" &&
1267 | typeof this.container.releasePointerCapture === "function"
1268 | ) {
1269 | try {
1270 | this.container.releasePointerCapture(capturedPointer);
1271 | } catch (err) {
1272 | // Ignore release errors
1273 | }
1274 | }
1275 |
1276 | if (this.container && this.container.style) {
1277 | this.container.style.scrollBehavior = previousScrollBehavior || "";
1278 | }
1279 | }
1280 |
1281 | getTrackedTouch(touchList, identifier) {
1282 | if (!touchList || identifier === null || typeof identifier === "undefined") {
1283 | return null;
1284 | }
1285 | for (let i = 0; i < touchList.length; i += 1) {
1286 | const touch = touchList[i];
1287 | if (touch.identifier === identifier) {
1288 | return touch;
1289 | }
1290 | }
1291 | return null;
1292 | }
1293 |
1294 | handleContainerClick(event) {
1295 | if (this.zoomMode !== "click" || !this.container) {
1296 | return;
1297 | }
1298 |
1299 | if (typeof event.button === "number" && event.button !== 0) {
1300 | return;
1301 | }
1302 |
1303 | const isZoomOut = event.altKey;
1304 | const hasZoomInModifier = event.metaKey || event.ctrlKey;
1305 |
1306 | if (!isZoomOut && !hasZoomInModifier) {
1307 | return;
1308 | }
1309 |
1310 | const focusPoint = this.getFocusPointFromEvent(event);
1311 |
1312 | const zoomOptions = { animate: true };
1313 | if (focusPoint) {
1314 | zoomOptions.focusX = focusPoint.baseX;
1315 | zoomOptions.focusY = focusPoint.baseY;
1316 | if (typeof focusPoint.pointerOffsetX === "number") {
1317 | zoomOptions.focusOffsetX = focusPoint.pointerOffsetX;
1318 | }
1319 | if (typeof focusPoint.pointerOffsetY === "number") {
1320 | zoomOptions.focusOffsetY = focusPoint.pointerOffsetY;
1321 | }
1322 | }
1323 |
1324 | if (isZoomOut) {
1325 | event.preventDefault();
1326 | event.stopPropagation();
1327 | const targetZoom = this.currentZoom - this.ZOOM_STEP;
1328 | this.setZoom(targetZoom, zoomOptions);
1329 | return;
1330 | }
1331 |
1332 | if (hasZoomInModifier) {
1333 | event.preventDefault();
1334 | event.stopPropagation();
1335 | const targetZoom = this.currentZoom + this.ZOOM_STEP;
1336 | this.setZoom(targetZoom, zoomOptions);
1337 | }
1338 | }
1339 |
1340 | getCenterPoint() {
1341 | if (
1342 | this.manualCenter &&
1343 | Number.isFinite(this.manualCenter.x) &&
1344 | Number.isFinite(this.manualCenter.y)
1345 | ) {
1346 | return this.manualCenter;
1347 | }
1348 |
1349 | if (this.baseDimensions) {
1350 | return {
1351 | x: this.baseOrigin.x + this.baseDimensions.width / 2,
1352 | y: this.baseOrigin.y + this.baseDimensions.height / 2,
1353 | };
1354 | }
1355 |
1356 | return { x: 0, y: 0 };
1357 | }
1358 |
1359 | getVisibleCenterPoint() {
1360 | if (!this.container || !this.baseDimensions || !this.unitsPerCss) {
1361 | return this.getCenterPoint();
1362 | }
1363 |
1364 | const visibleCenterX =
1365 | this.container.scrollLeft + this.container.clientWidth / 2;
1366 | const visibleCenterY =
1367 | this.container.scrollTop + this.container.clientHeight / 2;
1368 |
1369 | const cssBaseX = visibleCenterX / (this.currentZoom || 1);
1370 | const cssBaseY = visibleCenterY / (this.currentZoom || 1);
1371 |
1372 | return {
1373 | x: this.baseOrigin.x + cssBaseX * (this.unitsPerCss.x || 1),
1374 | y: this.baseOrigin.y + cssBaseY * (this.unitsPerCss.y || 1),
1375 | };
1376 | }
1377 |
1378 | async copyCenterCoordinates() {
1379 | const point = this.getVisibleCenterPoint();
1380 | const message = `${point.x.toFixed(2)}, ${point.y.toFixed(2)}`;
1381 |
1382 | if (navigator.clipboard && navigator.clipboard.writeText) {
1383 | try {
1384 | await navigator.clipboard.writeText(message);
1385 | this.updateCoordOutput(`Copied: ${message}`);
1386 | return;
1387 | } catch (err) {
1388 | console.warn("Clipboard copy failed", err);
1389 | }
1390 | }
1391 |
1392 | this.updateCoordOutput(message);
1393 | this.fallbackPrompt(message);
1394 | }
1395 |
1396 | updateCoordOutput(text) {
1397 | if (!this.coordOutputEl) return;
1398 | this.coordOutputEl.textContent = text;
1399 | clearTimeout(this._coordTimeout);
1400 | this._coordTimeout = setTimeout(() => {
1401 | this.coordOutputEl.textContent = "";
1402 | }, 4000);
1403 | }
1404 |
1405 | fallbackPrompt(message) {
1406 | if (typeof window !== "undefined" && window.prompt) {
1407 | window.prompt("Copy coordinates", message);
1408 | }
1409 | }
1410 |
1411 | resolveMinZoom() {
1412 | if (Number.isFinite(this.MIN_ZOOM) && this.MIN_ZOOM > 0) {
1413 | return this.MIN_ZOOM;
1414 | }
1415 | if (
1416 | !this.container ||
1417 | !this.baseCssDimensions ||
1418 | !Number.isFinite(this.baseCssDimensions.width) ||
1419 | !Number.isFinite(this.baseCssDimensions.height) ||
1420 | this.baseCssDimensions.width <= 0 ||
1421 | this.baseCssDimensions.height <= 0
1422 | ) {
1423 | return null;
1424 | }
1425 | const containerWidth = this.container.clientWidth || 0;
1426 | const containerHeight = this.container.clientHeight || 0;
1427 | if (containerWidth <= 0 || containerHeight <= 0) {
1428 | return null;
1429 | }
1430 | const containerDiag = Math.sqrt(
1431 | containerWidth ** 2 + containerHeight ** 2
1432 | );
1433 | const svgDiag = Math.sqrt(
1434 | this.baseCssDimensions.width ** 2 + this.baseCssDimensions.height ** 2
1435 | );
1436 | if (!Number.isFinite(containerDiag) || !Number.isFinite(svgDiag)) {
1437 | return null;
1438 | }
1439 | if (svgDiag <= 0) {
1440 | return null;
1441 | }
1442 | const computedMin = Math.min(1, containerDiag / svgDiag);
1443 | if (Number.isFinite(computedMin) && computedMin > 0) {
1444 | this.MIN_ZOOM = computedMin;
1445 | return this.MIN_ZOOM;
1446 | }
1447 | return null;
1448 | }
1449 |
1450 | getEffectiveMinZoom() {
1451 | const resolved = this.resolveMinZoom();
1452 | if (Number.isFinite(resolved) && resolved > 0) {
1453 | return resolved;
1454 | }
1455 | if (Number.isFinite(this.MIN_ZOOM) && this.MIN_ZOOM > 0) {
1456 | return this.MIN_ZOOM;
1457 | }
1458 | return null;
1459 | }
1460 |
1461 | getEffectiveMaxZoom() {
1462 | if (Number.isFinite(this.MAX_ZOOM) && this.MAX_ZOOM > 0) {
1463 | return this.MAX_ZOOM;
1464 | }
1465 | return this.currentZoom || 1;
1466 | }
1467 |
1468 | getZoomStep() {
1469 | return Number.isFinite(this.ZOOM_STEP) && this.ZOOM_STEP > 0
1470 | ? this.ZOOM_STEP
1471 | : 0.1;
1472 | }
1473 |
1474 | getZoomTolerance() {
1475 | const step = this.getZoomStep();
1476 | return Math.max(1e-4, step / 1000);
1477 | }
1478 |
1479 | setManualCenter(x, y, options = {}) {
1480 | const shouldRecenter = Boolean(options.recenter);
1481 | if (Number.isFinite(x) && Number.isFinite(y)) {
1482 | this.manualCenter = { x, y };
1483 | if (shouldRecenter && this.container && this.baseDimensions) {
1484 | this.centerView({ focusX: x, focusY: y });
1485 | }
1486 | return;
1487 | }
1488 |
1489 | this.manualCenter = null;
1490 | if (shouldRecenter && this.container && this.baseDimensions) {
1491 | this.centerView();
1492 | }
1493 | }
1494 |
1495 | destroy() {
1496 | this.endDrag();
1497 | if (Array.isArray(this.cleanupHandlers)) {
1498 | while (this.cleanupHandlers.length) {
1499 | const cleanup = this.cleanupHandlers.pop();
1500 | try {
1501 | cleanup();
1502 | } catch (err) {
1503 | // Ignore cleanup errors
1504 | }
1505 | }
1506 | this.cleanupHandlers = [];
1507 | }
1508 | if (this.container) {
1509 | if (this.container.classList) {
1510 | this.container.classList.remove("is-dragging");
1511 | }
1512 | if (this.container.style) {
1513 | this.container.style.touchAction = "";
1514 | }
1515 | }
1516 | if (
1517 | typeof window !== "undefined" &&
1518 | this.wheelAnimationFrame &&
1519 | typeof window.cancelAnimationFrame === "function"
1520 | ) {
1521 | window.cancelAnimationFrame(this.wheelAnimationFrame);
1522 | this.wheelAnimationFrame = null;
1523 | }
1524 | if (
1525 | typeof window !== "undefined" &&
1526 | this.zoomAnimationFrame &&
1527 | typeof window.cancelAnimationFrame === "function"
1528 | ) {
1529 | window.cancelAnimationFrame(this.zoomAnimationFrame);
1530 | this.zoomAnimationFrame = null;
1531 | }
1532 | this.wheelDeltaBuffer = 0;
1533 | this.wheelFocusPoint = null;
1534 | this.dragListenersAttached = false;
1535 | }
1536 |
1537 | computeZoomTarget(direction) {
1538 | const step = this.getZoomStep();
1539 | const tolerance = this.getZoomTolerance();
1540 | const maxZoom = this.getEffectiveMaxZoom();
1541 | const minZoom =
1542 | this.getEffectiveMinZoom() ?? Math.max(0, this.currentZoom - step);
1543 |
1544 | if (direction === "in") {
1545 | const remaining = maxZoom - this.currentZoom;
1546 | if (remaining <= step + tolerance) {
1547 | return maxZoom;
1548 | }
1549 | return Math.min(maxZoom, this.currentZoom + step);
1550 | }
1551 |
1552 | const available = this.currentZoom - minZoom;
1553 | if (available <= step + tolerance) {
1554 | return minZoom;
1555 | }
1556 | return Math.max(minZoom, this.currentZoom - step);
1557 | }
1558 |
1559 | updateZoomButtonState() {
1560 | if (!Array.isArray(this.zoomInButtons) || !Array.isArray(this.zoomOutButtons)) {
1561 | return;
1562 | }
1563 | const tolerance = this.getZoomTolerance();
1564 | const maxZoom = this.getEffectiveMaxZoom();
1565 | const minZoom = this.getEffectiveMinZoom();
1566 |
1567 | const canZoomIn =
1568 | maxZoom - this.currentZoom > tolerance && maxZoom > 0;
1569 | const canZoomOut =
1570 | minZoom === null
1571 | ? true
1572 | : this.currentZoom - minZoom > tolerance;
1573 |
1574 | this.toggleButtonState(this.zoomInButtons, canZoomIn);
1575 | this.toggleButtonState(this.zoomOutButtons, canZoomOut);
1576 | }
1577 |
1578 | toggleButtonState(buttons, enabled) {
1579 | if (!Array.isArray(buttons)) {
1580 | return;
1581 | }
1582 | buttons.forEach((button) => {
1583 | if (!button) {
1584 | return;
1585 | }
1586 | if (enabled) {
1587 | button.disabled = false;
1588 | button.classList.remove("is-disabled");
1589 | button.removeAttribute("aria-disabled");
1590 | } else {
1591 | button.disabled = true;
1592 | button.classList.add("is-disabled");
1593 | button.setAttribute("aria-disabled", "true");
1594 | }
1595 | });
1596 | }
1597 | }
1598 | // Export for global use
1599 | window.SVGViewer = SVGViewer;
1600 |
1601 | // Support CommonJS/ESM consumers (e.g., unit tests).
1602 | if (typeof module !== "undefined" && module.exports) {
1603 | module.exports = SVGViewer;
1604 | }
1605 |
--------------------------------------------------------------------------------