require 'nokogiri' require 'addressable/uri' require 'sequel' require 'json' require 'htmlentities' require 'rack' require 'sprockets' # This gem provides mechanisms to allow ballons (or speech bubbles) to be # added/removed/edited over images of a HTML or XHTML document and to be # persisted. The edition of the ballons is possible by the javascript module # provided by this gem. The persistence is allowed by the Ballonizer class. # The Ballonizer class is basically a wrapper around the database used to # persist the ballons, and offer methods to process the requests made by # the client side (by a form created by the javascript module), and to modify # a (X)HTML document adding the ballons of the image over it. # # This class lacks a lot of features like: access to an abstraction of the # ballons, images and their relationship; control over users who edit the # ballons; access to the old versions of the ballon set of a image (that # are stored in the database, but only can be accessed directly by the # Sequel::Database object). It's a work in progress, be warned to use # carefully and motivated to contribute. # # The JavaScript library used to allow edition in the client side works # as follows: double click over the image add a ballon, double click over # a ballon allow edit the text, when the ballon lose the focus it returns # to the non-edition state, a ballon without text (or only with spaces) it's # automatically removed when lose focus, drag the ballon change its position # (restricted to image space), drag ballon by the right-bottom handle # resize the ballon (also restricted to image space). Any change in the ballons # make visible a button fixed in the right-top corner of the browser viewport. # Every time a ballons is changed (or added/removed) the json of a hidden # form is updated. The button submits this json by POST request to the url # configured by :form_handler_url setting. # # To the image be 'ballonized' it have to match the :img_to_ballonize_css_selector. # The 'ballonized' term here means: have the ballons added over the image in # ballonize_page. # # To use this class with your (rack isn't?) app you need to: create the # necessary tables in a Sequel::Database object with Ballonizer.create_tables; # create a ballonizer instance with the url where you gonna handle the ballon # change requests and where provide the assets. Handle the ballon changes request # in that url with process_submit. Call instance.ballonize_page over the html # documents that can have the images to be ballonized. Check if the image match # the css selector :img_to_ballonize_css_selector. # # What's explained above is basically the example you can access with # 'rake example' and is in the examples/ballonizer_app/config.ru file. # You can reset the database with 'rake db:reset' (and if you pass an argument # as 'rake db:reset[postgres://user:password@host:port/database_name]' # you can create the tables in the database already used by your app). # The tables names are: images, ballons, ballonized_image_versions, # ballonized_image_ballons. # # Changelog: # v0.5.1: # * js_load_snippet can take a settings arg too. Fixed ballonize_page to # use the :form_handler_url from the settings argument. # v0.5.0: # * The *_html_links methods can take a settings argument. # * Fixed bug where passing a new asset path to the ballonize_page don't # settings parameter change the asset path that it uses. # * Asset path settings now are parsed as real URIs (need to have a # trailing slash if the intent is use as a dir). # * Updated the rspec version used by the gem (fixed deprecation). # v0.4.0: # * Changed the way the Javascript module add containers in the page # to avoid creating invalid HTML4.0.1/XHTML1.1/HTML5 documents. # * Now the ballonize_page takes a mime-type argument to decide if # the page has to be parsed as XML or HTML (trying to be in # conformance with http://www.w3.org/TR/xhtml-media-types/). # * The change in the ballon size now change the font-size of the # ballon text. # * Database schema change, as consequence of the font-size change, # the database now stores the font-size. No migration provided for # databases in the old format, but the font-size field can be null. # The migration only require adding this column with null value to # all records (see the create_tables code). # * Fixed a bug in the Javascript module that give wrong position and # size values to all ballons that aren't edited/added before submmiting # (only if the image wasn't loaded before the javascript loading). # # @author Henrique Becker class Ballonizer # The superclass of any error explicitly raised by the Ballonizer class. class Error < ArgumentError; end # The class used in exceptions related to a invalid value for a submit. class SubmitError < Error; end attr_accessor :db, :settings # @api private Don't use the methods of this module. They are for internal use only. module Workaround def self.join_uris(base, relative) Addressable::URI.parse(base).join(relative).to_s end def self.deep_freeze(e) e.each { | v | deep_freeze(v) } if e.is_a?(Enumerable) e.freeze end def self.parse_html_or_xhtml(doc, mime_type) # If you parse XHTML as HTML with Nokogiri, and use to_s after, the markup # can be messed up, breaking the structural integrity of the xml # # Example: # becomes # # In the other side if you parse HTML as a XML, and use to_s after, the # Nokogiri make empty content tags self-close # # Example: # becomes: EOF end # Executes the create_table operations over the Sequel::Database argument. # @param db [Sequel::Database] The database where create the tables. # @return [void] def self.create_tables(db) db.create_table(:images) do primary_key :id String :img_src, :size => 255, :unique => true, :allow_null => false end db.create_table(:ballons) do primary_key :id String :text, :size => 255, :allow_null => false Float :top, :allow_null => false Float :left, :allow_null => false Float :width, :allow_null => false Float :height, :allow_null => false # the font_size allow null to support databases migrated from old versions # (that don't have this field) Float :font_size, :allow_null => true end db.create_table(:ballonized_image_versions) do Integer :version foreign_key :image_id, :images DateTime :time, :allow_null => false primary_key [:version, :image_id] end db.create_table(:ballonized_image_ballons) do Integer :version foreign_key :image_id, :images foreign_key :ballon_id, :ballons foreign_key [:version, :image_id], :ballonized_image_versions end end # The (X)HTML fragment with the link tags that are added to the page by # ballonize_page if the :add_required_css setting is true (the default # is false). # @param settings [Hash{Symbol => String}] Optional. Hash to be merged with # the instance #settings (this argument override the #settings ones). # @return [String,NilClass] A String when the :css_asset_path_for_link is # defined, nil otherwise. def css_html_links(settings = {}) settings = @settings.merge(settings) return nil unless settings[:css_asset_path_for_link] link_template = '' css_paths = self.class.asset_logical_paths.select do | p | /^.+\.css$/.match(p) end links = css_paths.map do | p | p = Workaround.join_uris(settings[:css_asset_path_for_link], p) link_template.sub('PATH', p) end links.join('') end # The (X)HTML fragment with the script tags that are added to the page by # ballonize_page if the :add_required_js_libs_for_edition setting is true # (the default is false). # @param settings [Hash{Symbol => String}] Optional. Hash to be merged with # the instance #settings (this argument override the #settings ones). # @return [String,NilClass] A String when the :js_asset_path_for_link is # defined, nil otherwise. def js_libs_html_links(settings = {}) settings = self.settings.merge(settings) return nil unless settings[:js_asset_path_for_link] link_template = '' js_libs_paths = self.class.asset_logical_paths.select do | p | /^.+\.js$/.match(p) end links = js_libs_paths.map do | p | p = Workaround.join_uris(settings[:js_asset_path_for_link], p) link_template.sub('PATH', p) end links.join('') end # List of paths (relative to the gem root directory) to the directories with # the css and js provided by the gem. # @return [Array] A frozen array of frozen strings. def self.asset_load_paths return @asset_load_paths if @asset_load_paths absolute_lib_dir = File.dirname(File.realpath(__FILE__)) ballonizer_gem_root_dir = File.expand_path('../', absolute_lib_dir) @asset_load_paths = ASSETS.map do | load_path_and_files | load_path = load_path_and_files.first File.expand_path(load_path, ballonizer_gem_root_dir) end @asset_load_paths.flatten! @asset_load_paths.freeze end # List of logical paths to the css and js assets. The assets_app respond to # any requisition to one of these paths. # @return [Array] A frozen array of frozen strings. def self.asset_logical_paths return @asset_logical_paths if @asset_logical_paths @asset_logical_paths = ASSETS.map do | load_path_and_files | load_path_and_files.last end @asset_logical_paths.flatten! @asset_logical_paths.freeze end # List of absolute filepaths to the css and js files needed by the client # counterpart and provided by the gem. To all who not want to use assets_app. # @return [Array] A frozen array of frozen strings. # @see Ballonizer.assets_app def self.asset_absolute_paths return @asset_absolute_paths if @asset_absolute_paths absolute_lib_dir = File.dirname(File.realpath(__FILE__)) ballonizer_gem_root_dir = File.expand_path('../', absolute_lib_dir) @asset_absolute_paths = ASSETS.map do | load_path_and_files | relative_load_path, filepaths = *load_path_and_files absolute_load_path = File.expand_path(relative_load_path, ballonizer_gem_root_dir) filepaths.map do | filepath | File.expand_path(filepath, absolute_load_path) end end @asset_absolute_paths.flatten! @asset_absolute_paths.freeze end # A Rack app that provide the gem css and js. Each call to this method return # a new object (clone). The Sprockets::Environment isn't frozen because it # can't be used with 'run' in a rack app if frozen. # @return [Sprockets::Environment] # @see Ballonizer.assets_app def self.assets_app # dont freeze because run don't work in a frozen sprockets env return @assets_app.clone if @assets_app @assets_app = Sprockets::Environment.new asset_load_paths.each do | load_path | @assets_app.prepend_path load_path end @assets_app.clone end end