Module:Sandbox/TheUnit 72/Universal clickable button/sandbox
Appearance
| This is the module sandbox page for Module:Sandbox/TheUnit 72/Universal clickable button (diff). |
local p = {}
local colorMap = {
red = "#d33333",
["deep red"] = "#8b0000",
["light red"] = "#ff7f7f",
orange = "#ff8c00",
["light orange"] = "#ffd1a3",
yellow = "#ffeb3b",
gold = "#d4af37",
["deep yellow"] = "#d4af37",
["light yellow"] = "#fff59d",
green = "#4caf50",
["deep green"] = "#1b5e20",
["light green"] = "#88e788",
turquoise = "#40e0d0",
turqoise = "#40e0d0",
blue = "#3366cc",
["deep blue"] = "#0d47a1",
["light blue"] = "#90caf9",
purple = "#7953a9",
["deep purple"] = "#4a148c",
["light purple"] = "#ce93d8",
pink = "#e91e63",
magenta = "#c2185b",
white = "#ffffff",
black = "#000000",
grey = "#a2a9b1",
gray = "#a2a9b1",
silver = "#c8ccd1",
["light grey"] = "#f8f9fa",
["deep grey"] = "#54595d",
["charcoal"] = "#36454f",
beige = "#f8eaba",
["deep beige"] = "#c0c090",
["progressive"] = "#3366cc",
["destructive"] = "#bf3c2c",
["constructive"] = "#008000",
["effective"] = "#d4af37",
["neutral"] = "#f8f9fa"
}
local fontMap = {
["sans-serif"] = "sans-serif",
["sans"] = "sans-serif",
["serif"] = "serif",
["monospace"] = "monospace",
["mono"] = "monospace",
["georgia"] = "Georgia, serif",
["palatino"] = "Palatino, serif",
["garamond"] = "Garamond, serif",
["helvetica"] = "Helvetica, Arial, sans-serif",
["arial"] = "Arial, sans-serif",
["verdana"] = "Verdana, sans-serif",
["trebuchet"] = "Trebuchet, sans-serif",
["tahoma"] = "Tahoma, sans-serif",
["courier"] = "Courier, monospace",
["times"] = "Times, serif",
["linux libertine"] = "Georgia, serif"
}
local DEFAULT_BG = "#f8f9fa"
local DEFAULT_FG = "#000"
local SPECIAL_BORDERS = {
["#f8f9fa"] = "#a2a9b1",
["#f8eaba"] = "#c0c090"
}
local SIGNAL_PRESETS = {
activate = true,
deactivate = true,
toggle = true
}
local CONTENT_PRESETS = {
invoke = true,
dropdown = true
}
local DEFAULT_PAD_V = 5
local DEFAULT_PAD_H = 12
local DEFAULT_FONT = 14
local DEFAULT_RADIUS = 2
local function normalizeKey(str)
if not str then return nil end
str = tostring(str):lower()
:gsub("^%s+", "")
:gsub("%s+$", "")
:gsub("%s+", " ")
str = str:gsub("^dark%s+", "deep ")
return str
end
local function resolveColor(input)
if not input then return nil end
local key = normalizeKey(input)
if colorMap[key] then return colorMap[key] end
if key:match("^#%x%x%x$") or key:match("^#%x%x%x%x%x%x$") then return key end
if key:match("^%x%x%x$") or key:match("^%x%x%x%x%x%x$") then return "#" .. key end
return nil
end
local function expandHex(hex)
hex = hex:gsub("#", "")
if #hex == 3 then
return hex:sub(1,1) .. hex:sub(1,1)
.. hex:sub(2,2) .. hex:sub(2,2)
.. hex:sub(3,3) .. hex:sub(3,3)
elseif #hex == 6 then
return hex
end
return "808080"
end
local function getRGB(hex)
local h = expandHex(hex)
local r = tonumber(h:sub(1,2), 16)
local g = tonumber(h:sub(3,4), 16)
local b = tonumber(h:sub(5,6), 16)
return r, g, b
end
local function getLuminance(hex)
local r, g, b = getRGB(hex)
return (0.299 * r + 0.587 * g + 0.114 * b)
end
local function getTextColor(bg)
return (getLuminance(bg) > 186) and "#000" or "#fff"
end
local function isLightnessScale(hex)
local r, g, b = getRGB(hex)
local max = math.max(r, g, b)
local min = math.min(r, g, b)
return (max - min) <= 20
end
local function shiftColor(hex, factor)
local r, g, b = getRGB(hex)
r = math.min(255, math.max(0, math.floor(r * factor)))
g = math.min(255, math.max(0, math.floor(g * factor)))
b = math.min(255, math.max(0, math.floor(b * factor)))
return string.format("#%02x%02x%02x", r, g, b)
end
local function luminanceDiff(hex1, hex2)
return math.abs(getLuminance(hex1) - getLuminance(hex2))
end
local DARKEN_FACTOR = 0.70
local LIGHTEN_FACTOR = 1 / DARKEN_FACTOR
local MIN_DIFF = 12
local function isTruthy(val)
if not val then return false end
local s = tostring(val):lower():gsub("^%s+",""):gsub("%s+$","")
return s == "yes" or s == "true" or s == "1"
end
local function computeBorderColor(bg, lum, args)
local override = args.bordercolor
and tostring(args.bordercolor):gsub("^%s+",""):gsub("%s+$","")
or nil
if override and override ~= "" then
return resolveColor(override) or override
end
if SPECIAL_BORDERS[bg] then
return SPECIAL_BORDERS[bg]
end
local darkborder = isTruthy(args.darkborder)
local autoborder = isTruthy(args.autoborder) or isTruthy(args.allborder)
if isLightnessScale(bg) then
if lum > 186 then
return "#000000"
elseif darkborder then
return "#ffffff"
end
end
if autoborder then
local darkened = shiftColor(bg, DARKEN_FACTOR)
if luminanceDiff(bg, darkened) >= MIN_DIFF then
return darkened
end
local lightened = shiftColor(bg, LIGHTEN_FACTOR)
if luminanceDiff(bg, lightened) >= MIN_DIFF then
return lightened
end
return "#000000"
end
return nil
end
local function parseSize(raw)
if not raw or raw == "" then return nil end
raw = raw:gsub("%s+", "")
local factor = raw:match("^([%d%.]+)x$")
if factor then
local f = tonumber(factor)
if f and f > 0 then
return { mode = "scale", factor = f }
end
end
local w, h = raw:match("^(%d+)px,(%d+)px$")
if w and h then
return { mode = "px", w = tonumber(w), h = tonumber(h) }
end
local single = raw:match("^(%d+)px$")
if single then
local v = tonumber(single)
return { mode = "px", w = v, h = v }
end
return nil
end
local function parsePadding(raw)
if not raw or raw == "" then return 0, 0 end
raw = raw:gsub("%s+", "")
local v, h = raw:match("^(%d+)px,(%d+)px$")
if v and h then return tonumber(v), tonumber(h) end
local single = raw:match("^(%d+)px$")
if single then
local n = tonumber(single)
return n, n
end
local bare = raw:match("^(%d+)$")
if bare then
local n = tonumber(bare)
return n, n
end
return 0, 0
end
local function resolveFont(raw)
if not raw or raw == "" then return nil end
local key = tostring(raw):lower():gsub("^%s+",""):gsub("%s+$","")
return fontMap[key] or nil
end
local function parseFontSize(raw)
if not raw or raw == "" then return nil end
raw = tostring(raw):gsub("%s+","")
local mult = raw:match("^([%d%.]+)x$")
if mult then
local f = tonumber(mult)
if f and f > 0 then return { mode = "scale", factor = f } end
end
local px = raw:match("^(%d+)px$")
if px then return { mode = "px", value = tonumber(px) } end
local bare = raw:match("^(%d+)$")
if bare then return { mode = "px", value = tonumber(bare) } end
return nil
end
local function pickBaseColor(args)
local raw = args.color or args.colour or args[3]
local resolved = resolveColor(raw)
if resolved then
return resolved, getTextColor(resolved)
end
return DEFAULT_BG, DEFAULT_FG
end
local function isValidUrl(url)
if not url or url == "" then return false end
return url:match("^https?://") ~= nil
or url:match("^//") ~= nil
end
local ucbCounter = 0
local function nextUcbId()
ucbCounter = ucbCounter + 1
return "ucb-content-" .. ucbCounter
end
local function buildSiblingContent(frame, purposeinfo, contentId, isDropdown)
local trimmed = purposeinfo:gsub("^%s+", ""):gsub("%s+$", "")
local toExpand
if trimmed:match("^Module:") then
toExpand = "{{#invoke:" .. trimmed .. "}}"
else
toExpand = trimmed
end
local expanded = frame:preprocess(toExpand)
local sibling = mw.html.create("span")
sibling
:attr("id", contentId)
:attr("data-ucb-content", "1")
:css("display", "none")
if isDropdown then
sibling:addClass("ucb-dropdown")
else
sibling:addClass("ucb-content")
end
sibling:wikitext(expanded)
return tostring(sibling)
end
local function hexToRgba(hex, alpha)
local r, g, b = getRGB(hex)
return string.format("rgba(%d, %d, %d, %s)", r, g, b, tostring(alpha))
end
local function buildDisplay(frame, args, bg, fg)
local lum = getLuminance(bg)
local borderColor = computeBorderColor(bg, lum, args)
local purpose = args.purpose and normalizeKey(args.purpose) or nil
local longpurpose = args.longpurpose and normalizeKey(args.longpurpose) or nil
local purposeinfo = args.purposeinfo and tostring(args.purposeinfo) or nil
local sizeSpec = parseSize(args.size)
local addPadV, addPadH = parsePadding(args.padding)
local fontFamily = resolveFont(args.font)
local fontSizeSpec = parseFontSize(args.fontsize)
local span = mw.html.create("span")
span
:addClass("ucb-button")
:css({
["background-color"] = bg,
["color"] = fg,
["vertical-align"] = "middle",
["box-sizing"] = "border-box"
})
local borderweight = args.borderweight and tonumber(args.borderweight) or nil
if borderweight and borderweight > 0 and borderColor then
span:css("border-color", "transparent")
span:css(
"box-shadow",
string.format(
"inset 0 0 0 %dpx %s",
math.floor(borderweight),
hexToRgba(borderColor, 1)
)
)
elseif borderColor then
span:css("border-color", borderColor)
end
if fontFamily then
span:css("font-family", fontFamily)
end
local finalFontSize = nil
if fontSizeSpec then
if fontSizeSpec.mode == "px" then
if sizeSpec and sizeSpec.mode == "scale" then
finalFontSize = math.floor(fontSizeSpec.value * sizeSpec.factor)
else
finalFontSize = fontSizeSpec.value
end
elseif fontSizeSpec.mode == "scale" then
local base = DEFAULT_FONT * fontSizeSpec.factor
if sizeSpec and sizeSpec.mode == "scale" then
finalFontSize = math.floor(base * sizeSpec.factor)
else
finalFontSize = math.floor(base)
end
end
elseif sizeSpec and sizeSpec.mode == "scale" then
finalFontSize = math.floor(DEFAULT_FONT * sizeSpec.factor)
end
if finalFontSize then
span:css("font-size", finalFontSize .. "px")
end
if sizeSpec then
if sizeSpec.mode == "scale" then
local f = sizeSpec.factor
span:css("border-radius", math.floor(DEFAULT_RADIUS * f) .. "px")
span:css("padding",
math.floor((DEFAULT_PAD_V + addPadV) * f) .. "px "
.. math.floor((DEFAULT_PAD_H + addPadH) * f) .. "px"
)
elseif sizeSpec.mode == "px" then
span:css("width", sizeSpec.w .. "px")
span:css("height", sizeSpec.h .. "px")
span:css("overflow", "hidden")
span:css("padding",
(DEFAULT_PAD_V + addPadV) .. "px "
.. (DEFAULT_PAD_H + addPadH) .. "px"
)
end
else
if addPadV > 0 or addPadH > 0 then
span:css("padding",
(DEFAULT_PAD_V + addPadV) .. "px "
.. (DEFAULT_PAD_H + addPadH) .. "px"
)
end
end
local siblingHtml = nil
if purpose and purpose ~= "" then
if not purposeinfo or purposeinfo == "" then
return '<span class="error">Universal Clickable Button: '
.. 'purpose="' .. purpose .. '" requires purposeinfo.</span>'
end
if SIGNAL_PRESETS[purpose] then
span:attr("data-purpose", purpose)
span:attr("data-purposeinfo", purposeinfo)
elseif CONTENT_PRESETS[purpose] then
local contentId = nextUcbId()
span:attr("data-purpose", purpose)
span:attr("data-purposeinfo", contentId)
siblingHtml = buildSiblingContent(
frame, purposeinfo, contentId, purpose == "dropdown"
)
else
local contentId = nextUcbId()
span:attr("data-purpose", "raw")
span:attr("data-purposeinfo", contentId)
siblingHtml = buildSiblingContent(
frame, purpose, contentId, false
)
end
end
if longpurpose and longpurpose ~= "" then
if not purposeinfo or purposeinfo == "" then
return '<span class="error">Universal Clickable Button: '
.. 'longpurpose="' .. longpurpose .. '" requires purposeinfo.</span>'
end
if SIGNAL_PRESETS[longpurpose] then
span:attr("data-longpurpose", longpurpose)
span:attr("data-longpurposeinfo", purposeinfo)
elseif CONTENT_PRESETS[longpurpose] then
local contentId = nextUcbId()
span:attr("data-longpurpose", longpurpose)
span:attr("data-longpurposeinfo", purposeinfo)
span:attr("data-longcontent-id", contentId)
local sibling = mw.html.create("span")
sibling
:attr("id", contentId)
:attr("data-ucb-content", "1")
:css("display", "none")
if longpurpose == "dropdown" then
sibling:addClass("ucb-dropdown")
else
sibling:addClass("ucb-content")
end
siblingHtml = (siblingHtml or "") .. tostring(sibling)
else
local contentId = nextUcbId()
span:attr("data-longpurpose", "raw")
span:attr("data-longpurposeinfo", purposeinfo)
span:attr("data-longcontent-id", contentId)
local sibling = mw.html.create("span")
sibling
:attr("id", contentId)
:attr("data-ucb-content", "1")
:css("display", "none")
:addClass("ucb-content")
siblingHtml = (siblingHtml or "") .. tostring(sibling)
end
end
local userClass = args.class
and tostring(args.class):lower():gsub("^%s+", ""):gsub("%s+$", "")
or nil
if userClass and userClass ~= "" and userClass ~= "mw-ui-button" then
span:addClass(userClass)
end
if args.tooltip and tostring(args.tooltip) ~= "" then
span:attr("title", tostring(args.tooltip))
end
if args.style then
span:cssText(args.style)
end
local text = args.text or args[2] or args[1] or ""
if args.icon and tostring(args.icon) ~= "" then
local filename = tostring(args.icon):gsub("^[Ff]ile:", "")
span:wikitext(string.format('[[File:%s|16px|link=]] ', filename))
end
span:wikitext(text)
local result = tostring(span)
if siblingHtml then
result = result .. siblingHtml
end
return result
end
function p.main(frame)
local args = frame:getParent().args
local styleLink = frame:extensionTag("templatestyles", "", {
src = "Module:Universal clickable button/styles.css"
})
local purpose = args.purpose and normalizeKey(args.purpose) or nil
local longpurpose = args.longpurpose and normalizeKey(args.longpurpose) or nil
local hasPurpose = (purpose and purpose ~= "")
or (longpurpose and longpurpose ~= "")
local bg, fg = pickBaseColor(args)
if hasPurpose then
return styleLink .. buildDisplay(frame, args, bg, fg)
end
local link = args.url or args[1] or args.link
if not link or tostring(link) == "" then
return ""
end
local isUrl = args.url ~= nil and tostring(args.url) ~= ""
if isUrl and not isValidUrl(tostring(args.url)) then
return '<span class="error">Universal Clickable Button: invalid or unsafe URL.</span>'
end
local useHover = not isUrl
and args.hover
and tostring(args.hover):lower():gsub("^%s+", ""):gsub("%s+$", "") == "yes"
if not isUrl then
local currentTitle = mw.title.getCurrentTitle()
local targetTitle = mw.title.new(tostring(link))
if targetTitle and currentTitle
and targetTitle.prefixedText == currentTitle.prefixedText then
return styleLink .. buildDisplay(frame, args, bg, fg)
end
end
local display = buildDisplay(frame, args, bg, fg)
local linkHtml
if useHover then
linkHtml = string.format('[[%s|%s]]', tostring(link), display)
return styleLink .. string.format('<span class="plainlinks">%s</span>', linkHtml)
end
local href
if isUrl then
href = tostring(args.url)
else
local titleObj = mw.title.new(tostring(link))
href = titleObj and titleObj:fullUrl() or tostring(link)
end
linkHtml = string.format('[%s %s]', href, display)
return styleLink .. string.format('<span class="plainlinks">%s</span>', linkHtml)
end
return p