mirror of
https://github.com/discourse/discourse.git
synced 2024-11-27 09:33:37 +08:00
f1cbc23f1e
This commit introduces an icon to all links in the sidebar. If an icon has not been configured, we will fall back to a generic "link" icon. As part of this commit, we also standardised the size of each prefix to 20px by 20px and set a fix margin. This is to allow sufficient space for text prefixes and image prefixes to be displayed. Tests have been intentionally left out for now as I don't feel like asserting for the icons will bring much value at this point. Time shall prove me wrong. Co-authored-by: awesomerobot <kris.aubuchon@discourse.org>
532 lines
12 KiB
Ruby
532 lines
12 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
module SvgSprite
|
|
SVG_ICONS ||= Set.new([
|
|
"adjust",
|
|
"address-book",
|
|
"ambulance",
|
|
"anchor",
|
|
"angle-double-down",
|
|
"angle-double-up",
|
|
"angle-double-right",
|
|
"angle-double-left",
|
|
"angle-down",
|
|
"angle-right",
|
|
"angle-up",
|
|
"archive",
|
|
"arrow-down",
|
|
"arrow-left",
|
|
"arrow-up",
|
|
"arrows-alt-h",
|
|
"arrows-alt-v",
|
|
"at",
|
|
"asterisk",
|
|
"backward",
|
|
"ban",
|
|
"bars",
|
|
"bed",
|
|
"bell",
|
|
"bell-slash",
|
|
"bold",
|
|
"book",
|
|
"book-reader",
|
|
"bookmark",
|
|
"briefcase",
|
|
"calendar-alt",
|
|
"caret-down",
|
|
"caret-left",
|
|
"caret-right",
|
|
"caret-up",
|
|
"certificate",
|
|
"chart-bar",
|
|
"chart-pie",
|
|
"check",
|
|
"check-circle",
|
|
"check-square",
|
|
"chevron-down",
|
|
"chevron-left",
|
|
"chevron-right",
|
|
"chevron-up",
|
|
"circle",
|
|
"code",
|
|
"cog",
|
|
"columns",
|
|
"comment",
|
|
"compress",
|
|
"copy",
|
|
"crosshairs",
|
|
"cube",
|
|
"desktop",
|
|
"discourse-amazon",
|
|
"discourse-bell-exclamation",
|
|
"discourse-bell-one",
|
|
"discourse-bell-slash",
|
|
"discourse-bookmark-clock",
|
|
"discourse-compress",
|
|
"discourse-emojis",
|
|
"discourse-expand",
|
|
"discourse-other-tab",
|
|
"download",
|
|
"ellipsis-h",
|
|
"ellipsis-v",
|
|
"envelope",
|
|
"envelope-square",
|
|
"exchange-alt",
|
|
"exclamation-circle",
|
|
"exclamation-triangle",
|
|
"external-link-alt",
|
|
"fab-android",
|
|
"fab-apple",
|
|
"fab-chrome",
|
|
"fab-discord",
|
|
"fab-discourse",
|
|
"fab-facebook-square",
|
|
"fab-facebook",
|
|
"fab-github",
|
|
"fab-instagram",
|
|
"fab-linux",
|
|
"fab-twitter",
|
|
"fab-twitter-square",
|
|
"fab-wikipedia-w",
|
|
"fab-windows",
|
|
"far-bell",
|
|
"far-bell-slash",
|
|
"far-calendar-plus",
|
|
"far-chart-bar",
|
|
"far-check-square",
|
|
"far-circle",
|
|
"far-clipboard",
|
|
"far-clock",
|
|
"far-comment",
|
|
"far-comments",
|
|
"far-copyright",
|
|
"far-dot-circle",
|
|
"far-edit",
|
|
"far-envelope",
|
|
"far-eye",
|
|
"far-eye-slash",
|
|
"far-file-alt",
|
|
"far-frown",
|
|
"far-heart",
|
|
"far-image",
|
|
"far-list-alt",
|
|
"far-meh",
|
|
"far-moon",
|
|
"far-smile",
|
|
"far-square",
|
|
"far-star",
|
|
"far-sun",
|
|
"far-thumbs-down",
|
|
"far-thumbs-up",
|
|
"far-trash-alt",
|
|
"fast-backward",
|
|
"fast-forward",
|
|
"file",
|
|
"file-alt",
|
|
"filter",
|
|
"flag",
|
|
"folder",
|
|
"folder-open",
|
|
"forward",
|
|
"gavel",
|
|
"globe",
|
|
"globe-americas",
|
|
"hand-point-right",
|
|
"hands-helping",
|
|
"heart",
|
|
"history",
|
|
"home",
|
|
"hourglass-start",
|
|
"id-card",
|
|
"image",
|
|
"inbox",
|
|
"info-circle",
|
|
"italic",
|
|
"key",
|
|
"keyboard",
|
|
"layer-group",
|
|
"link",
|
|
"list",
|
|
"list-ol",
|
|
"list-ul",
|
|
"lock",
|
|
"magic",
|
|
"map-marker-alt",
|
|
"microphone-slash",
|
|
"minus",
|
|
"minus-circle",
|
|
"mobile-alt",
|
|
"moon",
|
|
"paint-brush",
|
|
"paper-plane",
|
|
"pause",
|
|
"pencil-alt",
|
|
"play",
|
|
"plug",
|
|
"plus",
|
|
"plus-circle",
|
|
"plus-square",
|
|
"power-off",
|
|
"puzzle-piece",
|
|
"question",
|
|
"question-circle",
|
|
"quote-left",
|
|
"quote-right",
|
|
"random",
|
|
"redo",
|
|
"reply",
|
|
"rocket",
|
|
"search",
|
|
"share",
|
|
"shield-alt",
|
|
"sign-in-alt",
|
|
"sign-out-alt",
|
|
"signal",
|
|
"sliders-h",
|
|
"square-full",
|
|
"star",
|
|
"step-backward",
|
|
"step-forward",
|
|
"stream",
|
|
"sync-alt",
|
|
"sync",
|
|
"table",
|
|
"tag",
|
|
"tags",
|
|
"tasks",
|
|
"thermometer-three-quarters",
|
|
"thumbs-down",
|
|
"thumbs-up",
|
|
"thumbtack",
|
|
"times",
|
|
"times-circle",
|
|
"toggle-off",
|
|
"toggle-on",
|
|
"trash-alt",
|
|
"undo",
|
|
"unlink",
|
|
"unlock",
|
|
"unlock-alt",
|
|
"upload",
|
|
"user",
|
|
"user-cog",
|
|
"user-edit",
|
|
"user-friends",
|
|
"user-plus",
|
|
"user-secret",
|
|
"user-shield",
|
|
"user-times",
|
|
"users",
|
|
"wrench",
|
|
"spinner",
|
|
"tippy-rounded-arrow"
|
|
])
|
|
|
|
FA_ICON_MAP = { 'far fa-' => 'far-', 'fab fa-' => 'fab-', 'fas fa-' => '', 'fa-' => '' }
|
|
|
|
CORE_SVG_SPRITES = Dir.glob("#{Rails.root}/vendor/assets/svg-icons/**/*.svg")
|
|
|
|
THEME_SPRITE_VAR_NAME = "icons-sprite"
|
|
|
|
def self.preload
|
|
settings_icons
|
|
group_icons
|
|
badge_icons
|
|
end
|
|
|
|
def self.custom_svg_sprites(theme_id)
|
|
get_set_cache("custom_svg_sprites_#{Theme.transform_ids(theme_id).join(',')}") do
|
|
plugin_paths = []
|
|
Discourse.plugins.map { |plugin| File.dirname(plugin.path) }.each do |path|
|
|
plugin_paths << "#{path}/svg-icons/*.svg"
|
|
end
|
|
|
|
custom_sprite_paths = Dir.glob(plugin_paths)
|
|
|
|
custom_sprites = custom_sprite_paths.map do |path|
|
|
if File.exist?(path)
|
|
{
|
|
filename: "#{File.basename(path, ".svg")}",
|
|
sprite: File.read(path)
|
|
}
|
|
end
|
|
end
|
|
|
|
if theme_id.present?
|
|
ThemeField.where(type_id: ThemeField.types[:theme_upload_var], name: THEME_SPRITE_VAR_NAME, theme_id: Theme.transform_ids(theme_id))
|
|
.pluck(:upload_id, :theme_id).each do |upload_id, child_theme_id|
|
|
|
|
begin
|
|
upload = Upload.find(upload_id)
|
|
custom_sprites << {
|
|
filename: "theme_#{theme_id}_#{upload_id}.svg",
|
|
sprite: upload.content
|
|
}
|
|
rescue => e
|
|
name = Theme.find(child_theme_id).name rescue nil
|
|
Discourse.warn_exception(e, message: "#{name} theme contains a corrupt svg upload")
|
|
end
|
|
|
|
end
|
|
end
|
|
custom_sprites
|
|
end
|
|
end
|
|
|
|
def self.all_icons(theme_id = nil)
|
|
get_set_cache("icons_#{Theme.transform_ids(theme_id).join(',')}") do
|
|
Set.new()
|
|
.merge(settings_icons)
|
|
.merge(plugin_icons)
|
|
.merge(badge_icons)
|
|
.merge(group_icons)
|
|
.merge(theme_icons(theme_id))
|
|
.merge(custom_icons(theme_id))
|
|
.delete_if { |i| i.blank? || i.include?("/") }
|
|
.map! { |i| process(i.dup) }
|
|
.merge(SVG_ICONS)
|
|
.sort
|
|
end
|
|
end
|
|
|
|
def self.version(theme_id = nil)
|
|
get_set_cache("version_#{Theme.transform_ids(theme_id).join(',')}") do
|
|
Digest::SHA1.hexdigest(bundle(theme_id))
|
|
end
|
|
end
|
|
|
|
def self.path(theme_id = nil)
|
|
"/svg-sprite/#{Discourse.current_hostname}/svg-#{theme_id}-#{version(theme_id)}.js"
|
|
end
|
|
|
|
def self.expire_cache
|
|
cache&.clear
|
|
end
|
|
|
|
def self.sprite_sources(theme_id)
|
|
sprites = []
|
|
|
|
CORE_SVG_SPRITES.each do |path|
|
|
if File.exist?(path)
|
|
sprites << {
|
|
filename: "#{File.basename(path, ".svg")}",
|
|
sprite: File.read(path)
|
|
}
|
|
end
|
|
end
|
|
|
|
if theme_id.present?
|
|
sprites = sprites + custom_svg_sprites(theme_id)
|
|
end
|
|
|
|
sprites
|
|
end
|
|
|
|
def self.core_svgs
|
|
@core_svgs ||= begin
|
|
symbols = {}
|
|
|
|
CORE_SVG_SPRITES.each do |filename|
|
|
svg_filename = "#{File.basename(filename, ".svg")}"
|
|
|
|
Nokogiri::XML(File.open(filename)) do |config|
|
|
config.options = Nokogiri::XML::ParseOptions::NOBLANKS
|
|
end.css('symbol').each do |sym|
|
|
icon_id = prepare_symbol(sym, svg_filename)
|
|
sym.attributes['id'].value = icon_id
|
|
symbols[icon_id] = sym.to_xml
|
|
end
|
|
end
|
|
|
|
symbols
|
|
end
|
|
end
|
|
|
|
def self.bundle(theme_id = nil)
|
|
icons = all_icons(theme_id)
|
|
|
|
svg_subset = """<!--
|
|
Discourse SVG subset of Font Awesome Free by @fontawesome - https://fontawesome.com
|
|
License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License)
|
|
-->
|
|
<svg xmlns='http://www.w3.org/2000/svg' style='display: none;'>
|
|
""".dup
|
|
|
|
core_svgs.each do |icon_id, sym|
|
|
if icons.include?(icon_id)
|
|
svg_subset << sym
|
|
end
|
|
end
|
|
|
|
custom_svg_sprites(theme_id).each do |item|
|
|
begin
|
|
svg_file = Nokogiri::XML(item[:sprite]) do |config|
|
|
config.options = Nokogiri::XML::ParseOptions::NOBLANKS
|
|
end
|
|
rescue => e
|
|
Rails.logger.warn("Bad XML in custom sprite in theme with ID=#{theme_id}. Error info: #{e.inspect}")
|
|
end
|
|
|
|
next if !svg_file
|
|
|
|
svg_file.css("symbol").each do |sym|
|
|
icon_id = prepare_symbol(sym, item[:filename])
|
|
|
|
if icons.include? icon_id
|
|
sym.attributes['id'].value = icon_id
|
|
sym.css('title').each(&:remove)
|
|
svg_subset << sym.to_xml
|
|
end
|
|
end
|
|
end
|
|
|
|
svg_subset << '</svg>'
|
|
end
|
|
|
|
def self.search(searched_icon)
|
|
searched_icon = process(searched_icon.dup)
|
|
|
|
sprite_sources(SiteSetting.default_theme_id).each do |item|
|
|
svg_file = Nokogiri::XML(item[:sprite])
|
|
|
|
svg_file.css('symbol').each do |sym|
|
|
icon_id = prepare_symbol(sym, item[:filename])
|
|
|
|
if searched_icon == icon_id
|
|
sym.attributes['id'].value = icon_id
|
|
sym.css('title').each(&:remove)
|
|
return sym.to_xml
|
|
end
|
|
end
|
|
end
|
|
|
|
false
|
|
end
|
|
|
|
def self.icon_picker_search(keyword)
|
|
results = Set.new
|
|
|
|
sprite_sources(SiteSetting.default_theme_id).each do |item|
|
|
svg_file = Nokogiri::XML(item[:sprite])
|
|
|
|
svg_file.css('symbol').each do |sym|
|
|
icon_id = prepare_symbol(sym, item[:filename])
|
|
if keyword.empty? || icon_id.include?(keyword)
|
|
sym.attributes['id'].value = icon_id
|
|
sym.css('title').each(&:remove)
|
|
results.add(id: icon_id, symbol: sym.to_xml)
|
|
end
|
|
end
|
|
end
|
|
|
|
results.sort_by { |icon| icon[:id] }
|
|
end
|
|
|
|
# For use in no_ember .html.erb layouts
|
|
def self.raw_svg(name)
|
|
get_set_cache("raw_svg_#{name}") do
|
|
symbol = search(name)
|
|
break "" unless symbol
|
|
symbol = Nokogiri::XML(symbol).children.first
|
|
symbol.name = "svg"
|
|
<<~HTML
|
|
<svg class="fa d-icon svg-icon svg-node" aria-hidden="true">#{symbol}</svg>
|
|
HTML
|
|
end.html_safe
|
|
end
|
|
|
|
def self.theme_sprite_variable_name
|
|
THEME_SPRITE_VAR_NAME
|
|
end
|
|
|
|
def self.prepare_symbol(symbol, svg_filename = nil)
|
|
icon_id = symbol.attr('id')
|
|
|
|
case svg_filename
|
|
when "regular"
|
|
icon_id = icon_id.prepend('far-')
|
|
when "brands"
|
|
icon_id = icon_id.prepend('fab-')
|
|
end
|
|
|
|
icon_id
|
|
end
|
|
|
|
def self.settings_icons
|
|
get_set_cache("settings_icons") do
|
|
# includes svg_icon_subset and any settings containing _icon (incl. plugin settings)
|
|
site_setting_icons = []
|
|
|
|
SiteSetting.settings_hash.select do |key, value|
|
|
if key.to_s.include?("_icon") && String === value
|
|
site_setting_icons |= value.split('|')
|
|
end
|
|
end
|
|
|
|
site_setting_icons
|
|
end
|
|
end
|
|
|
|
def self.plugin_icons
|
|
DiscoursePluginRegistry.svg_icons
|
|
end
|
|
|
|
def self.badge_icons
|
|
get_set_cache("badge_icons") do
|
|
Badge.pluck(:icon).uniq
|
|
end
|
|
end
|
|
|
|
def self.group_icons
|
|
get_set_cache("group_icons") do
|
|
Group.pluck(:flair_icon).uniq
|
|
end
|
|
end
|
|
|
|
def self.theme_icons(theme_id)
|
|
return [] if theme_id.blank?
|
|
|
|
theme_icon_settings = []
|
|
theme_ids = Theme.transform_ids(theme_id)
|
|
|
|
# Need to load full records for default values
|
|
Theme.where(id: theme_ids).each do |theme|
|
|
_settings = theme.cached_settings.each do |key, value|
|
|
if key.to_s.include?("_icon") && String === value
|
|
theme_icon_settings |= value.split('|')
|
|
end
|
|
end
|
|
end
|
|
|
|
theme_icon_settings |= ThemeModifierHelper.new(theme_ids: theme_ids).svg_icons
|
|
|
|
theme_icon_settings
|
|
end
|
|
|
|
def self.custom_icons(theme_id)
|
|
# Automatically register icons in sprites added via themes or plugins
|
|
icons = []
|
|
custom_svg_sprites(theme_id).each do |item|
|
|
svg_file = Nokogiri::XML(item[:sprite])
|
|
svg_file.css('symbol').each do |sym|
|
|
icons << sym.attributes['id'].value if sym.attributes['id'].present?
|
|
end
|
|
end
|
|
icons
|
|
end
|
|
|
|
def self.process(icon_name)
|
|
icon_name = icon_name.strip
|
|
FA_ICON_MAP.each { |k, v| icon_name = icon_name.sub(k, v) }
|
|
icon_name
|
|
end
|
|
|
|
def self.get_set_cache(key, &block)
|
|
cache.defer_get_set(key, &block)
|
|
end
|
|
|
|
def self.cache
|
|
@cache ||= DistributedCache.new('svg_sprite')
|
|
end
|
|
end
|