├── 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 |

12 | 13 | {% svg_viewer 14 | src="{{ svg_path }}" 15 | height="70vh" 16 | zoom="130" 17 | controls_position="bottom" 18 | controls_buttons="compact,aligncenter,zoom_in,zoom_out,reset,center" 19 | caption="SCROLL or pinch to zoom, DRAG to pan." 20 | %} 21 | 22 |

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 |

26 | 27 | -------------------------------------------------------------------------------- /lib/jekyll/svg_viewer/config.rb: -------------------------------------------------------------------------------- 1 | module Jekyll 2 | module SvgViewer 3 | module Config 4 | DEFAULTS = { 5 | "assets" => { 6 | "auto_include" => "auto", 7 | "cache_bust" => false 8 | }, 9 | "defaults" => { 10 | "height" => "600px", 11 | "class" => "", 12 | "zoom" => "100", 13 | "min_zoom" => "25", 14 | "max_zoom" => "800", 15 | "zoom_step" => "10", 16 | "center_x" => nil, 17 | "center_y" => nil, 18 | "show_coords" => false, 19 | "title" => "", 20 | "caption" => "", 21 | "controls_position" => "top", 22 | "controls_buttons" => "both", 23 | "button_fill" => "", 24 | "button_border" => "", 25 | "button_foreground" => "", 26 | "pan_mode" => "", 27 | "zoom_mode" => "" 28 | } 29 | }.freeze 30 | 31 | module_function 32 | 33 | def for(site) 34 | config = site.config.fetch("svg_viewer", {}) 35 | Jekyll::Utils.deep_merge_hashes(DEFAULTS, config) 36 | end 37 | end 38 | end 39 | end 40 | 41 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Brett Terpstra 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /jekyll-svg-viewer.gemspec: -------------------------------------------------------------------------------- 1 | require_relative "lib/jekyll/svg_viewer/version" 2 | 3 | Gem::Specification.new do |spec| 4 | spec.name = "jekyll-svg-viewer" 5 | spec.version = Jekyll::SvgViewer::VERSION 6 | spec.authors = ["Brett Terpstra"] 7 | spec.email = ["me@ttscoff.com"] 8 | 9 | spec.summary = "Liquid tag and preset builder for embedding interactive SVG viewers in Jekyll sites." 10 | spec.description = "Port of the WP SVG Viewer frontend packaged as a Jekyll plugin gem with configurable defaults, localized UI strings, and an optional preset builder page." 11 | spec.homepage = "https://github.com/ttscoff/wp-svg-viewer" 12 | spec.license = "MIT" 13 | 14 | spec.files = Dir.chdir(__dir__) do 15 | Dir["lib/**/*.rb"] + 16 | Dir["assets/svg-viewer/**/*"] + 17 | %w[README.md LICENSE.txt] 18 | end 19 | spec.bindir = "exe" 20 | spec.executables = [] 21 | spec.require_paths = ["lib"] 22 | 23 | spec.metadata["homepage_uri"] = spec.homepage 24 | spec.metadata["source_code_uri"] = "https://github.com/ttscoff/jekyll-svg-viewer" 25 | 26 | spec.add_dependency "jekyll", ">= 4.0", "< 5.0" 27 | spec.add_dependency "liquid", ">= 4.0" 28 | end 29 | 30 | -------------------------------------------------------------------------------- /lib/jekyll/svg_viewer/preview_page.rb: -------------------------------------------------------------------------------- 1 | require_relative "config" 2 | 3 | module Jekyll 4 | module SvgViewer 5 | class PreviewPage 6 | TEMPLATE_PATH = File.expand_path("../../../assets/svg-viewer/preview/index.html", __dir__) 7 | 8 | class << self 9 | def register(_site) 10 | return if hook_registered? 11 | 12 | @hook_registered = true 13 | 14 | Jekyll::Hooks.register :site, :post_read do |site| 15 | add_preview_page(site) 16 | end 17 | end 18 | 19 | private 20 | 21 | def hook_registered? 22 | @hook_registered ||= false 23 | end 24 | 25 | def add_preview_page(site) 26 | return unless File.exist?(TEMPLATE_PATH) 27 | 28 | runtime = site.config["svg_viewer_runtime"] ||= {} 29 | return if runtime["preview_page_registered"] 30 | 31 | site.pages << new_page(site) 32 | runtime["preview_page_registered"] = true 33 | end 34 | 35 | def new_page(site) 36 | config = Jekyll::SvgViewer::Config.for(site) 37 | data = { 38 | "layout" => nil, 39 | "title" => "SVG Viewer Preset Builder", 40 | "permalink" => "/svg-viewer/preset-builder/", 41 | "svg_viewer_defaults" => config["defaults"] 42 | } 43 | 44 | content = File.read(TEMPLATE_PATH) 45 | PageWithoutAFile.new(site, site.source, "svg-viewer/preset-builder", "index.html").tap do |page| 46 | page.data = data 47 | page.content = content 48 | end 49 | end 50 | end 51 | end 52 | end 53 | end 54 | 55 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rake/clean' 4 | require 'rubygems' 5 | require 'rubygems/package_task' 6 | 7 | spec = Gem::Specification.load('jekyll-svg-viewer.gemspec') 8 | 9 | Gem::PackageTask.new(spec) do |pkg| 10 | pkg.need_tar = false 11 | end 12 | 13 | CLEAN.include('pkg') 14 | CLOBBER.include('pkg') 15 | 16 | desc 'Development version check' 17 | task :ver do 18 | gver = `git ver` 19 | cver = IO.read(File.join(File.dirname(__FILE__), 'CHANGELOG.md')).match(/^#+ (\d+\.\d+\.\d+(\w+)?)/)[1] 20 | res = `grep VERSION lib/jekyll/svg_viewer/version.rb` 21 | version = res.match(/VERSION *= *['"](\d+\.\d+\.\d+(\w+)?)/)[1] 22 | puts "git tag: #{gver}" 23 | puts "version.rb: #{version}" 24 | puts "changelog: #{cver}" 25 | end 26 | 27 | desc 'Changelog version check' 28 | task :cver do 29 | puts IO.read(File.join(File.dirname(__FILE__), 'CHANGELOG.md')).match(/^#+ (\d+\.\d+\.\d+(\w+)?)/)[1] 30 | end 31 | 32 | desc 'Bump incremental version number' 33 | task :bump, :type do |_, args| 34 | args.with_defaults(type: 'inc') 35 | version_file = 'lib/jekyll/svg_viewer/version.rb' 36 | content = IO.read(version_file) 37 | content.sub!(/VERSION = '(?\d+)\.(?\d+)\.(?\d+)(?
\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 | ![Preset Editor](images/preset-editor.jpg "Preset Editor") 79 | 80 | ![Front End Rendering](images/front-end.jpg "Front End Rendering") 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 |
26 |
27 | 28 | 30 |
31 | 32 |
33 |
34 | 35 | 36 |
37 |
38 | 39 | 40 |
41 |
42 | 43 | 44 |
45 |
46 | 47 | 48 |
49 |
50 | 51 | 52 |
53 |
54 | 55 |
56 |
57 | 58 | 63 |
64 |
65 | 66 | 71 |
72 |
73 | 74 | 80 |
81 |
82 | 83 |
84 | 85 | 87 |
88 | 89 |
90 |
91 | 92 | 93 |
94 |
95 | 96 | 97 |
98 |
99 | 100 | 101 |
102 |
103 | 104 |
105 | 106 | 107 |
108 | 109 |
110 | 111 | 113 |
114 | 115 |
116 | 117 | 119 |
120 | 121 |
122 |
123 | 124 | 125 |
126 |
127 | 128 | 129 |
130 |
131 |
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 | 161 |
162 |
163 |

_config.yml Snippet

164 |

Copy these defaults (excluding the SVG path and captured center) into your site configuration.

165 |
166 | 167 |
168 | 169 | 170 |
171 |
172 |
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 |
342 | 349 |
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 |
510 | 518 |
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 | --------------------------------------------------------------------------------