1
0
Fork 0
mirror of https://github.com/FrankerFaceZ/FrankerFaceZ.git synced 2025-10-11 05:31:56 +00:00
This release implements a massive change to how link tool-tips and embeds work. They might act up a bit while we get the new back-end installed and tweaked.

* Added: Options to use custom formats for dates and times, under `Appearance > Localization`.
* Added: Options to change the colors of tool-tips.
* Changed: Completely rewrite how link information is formatted together with a complete rewrite of the link information service.
* Changed: The FFZ Control Center now remembers you previously open settings if you reload the page.
* Fixed: Update chat lines when i18n data loads.
* Fixed: i18n not correctly formatting certain numbers.
* Fixed: Theater mode automatically enabling on user home pages. (Closes #866)
* Fixed: Theater metadata overlapping chat with Swap Sidebars enabled. (Closes #835)
* API Added: New icons: `location`, `link`, and `volume-off`
* API Fixed: `createElement` not properly handling `<video>` related attributes.
This commit is contained in:
SirStendec 2020-08-04 18:26:11 -04:00
parent eec65551fb
commit 6310a2ed49
49 changed files with 2432 additions and 884 deletions

View file

@ -18,17 +18,11 @@ export const LV_SERVER = 'https://cbenni.com/api';
export const LV_SOCKET_SERVER = 'wss://cbenni.com/socket.io/';
export const ALLOWED_TAGS = [
'strong', 'em', 'i', 'b', 'time', 'br', 'hr', 'div', 'span', 'img', 'figure', 'p', 'a', 'video', 'audio', 'blockquote', 'heading', 'section', 'nav', 'footer', 'aside', 'article', 'source', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6'
];
export const ALLOWED_ATTRIBUTES = [
'datetime', 'src', 'href', 'style', 'alt', 'title', 'height', 'width', 'srcset', 'autoplay', 'volume', 'muted', 'loop', 'poster', 'type'
];
export const KEYS = {
Enter: 13,
Shift: 16,
Control: 17,
Alt: 18,
Escape: 27,
Space: 32,
PageUp: 33,
@ -38,7 +32,9 @@ export const KEYS = {
ArrowLeft: 37,
ArrowUp: 38,
ArrowRight: 39,
ArrowDown: 40
ArrowDown: 40,
Meta: 91,
Context: 93
};

View file

@ -3,11 +3,13 @@ query FFZ_FetchUser($id: ID, $login: String) {
id
login
displayName
description
profileImageURL(width: 50)
profileViewCount
primaryColorHex
broadcastSettings {
id
title
game {
id
displayName
@ -15,6 +17,7 @@ query FFZ_FetchUser($id: ID, $login: String) {
}
stream {
id
previewImageURL
}
followers {
totalCount

View file

@ -13,7 +13,7 @@ const ATTRS = [
'hidden', 'high', 'href', 'hreflang', 'http-equiv', 'icon', 'id',
'integrity', 'ismap', 'itemprop', 'keytype', 'kind', 'label', 'lang',
'language', 'list', 'loop', 'low', 'manifest', 'max', 'maxlength',
'minlength', 'media', 'method', 'min', 'multiple', 'muted', 'name',
'minlength', 'media', 'method', 'min', 'multiple', 'name',
'novalidate', 'open', 'optimum', 'pattern', 'ping', 'placeholder', 'poster',
'preload', 'radiogroup', 'readonly', 'rel', 'required', 'reversed', 'rows',
'rowspan', 'sandbox', 'scope', 'scoped', 'seamless', 'selected', 'shape',
@ -22,6 +22,10 @@ const ATTRS = [
'title', 'type', 'usemap', 'value', 'width', 'wrap'
];
const BOOLEAN_ATTRS = [
'controls', 'autoplay', 'loop'
];
const range = document.createRange();
@ -95,8 +99,12 @@ export function createElement(tag, props, ...children) {
el.style.cssText = prop;
else
for(const k in prop)
if ( has(prop, k) )
el.style[k] = prop[k];
if ( has(prop, k) ) {
if ( has(el.style, k) )
el.style[k] = prop[k];
else
el.style.setProperty(k, prop[k]);
}
} else if ( lk === 'dataset' ) {
for(const k in prop)
@ -114,11 +122,16 @@ export function createElement(tag, props, ...children) {
else if ( lk.startsWith('data-') )
el.dataset[camelCase(lk.slice(5))] = prop;
else if ( lk.startsWith('aria-') || ATTRS.includes(lk) )
else if ( BOOLEAN_ATTRS.includes(lk) ) {
if ( prop && prop !== 'false' )
el.setAttribute(key, prop);
console.log('bool-attr', key, prop);
} else if ( lk.startsWith('aria-') || ATTRS.includes(lk) )
el.setAttribute(key, prop);
else
el[key] = props[key];
el[key] = prop;
}
if ( children )

View file

@ -97,5 +97,8 @@ export default [
"viewers",
"move",
"chat-empty",
"chat"
"chat",
"location",
"link",
"volume-off"
];

View file

@ -540,6 +540,51 @@ export function glob_to_regex(input) {
}
/**
* Truncate a string. Tries to intelligently break the string in white-space
* if possible, without back-tracking. The returned string can be up to
* `ellipsis.length + target + overage` characters long.
* @param {String} str The string to truncate.
* @param {Number} target The target length for the result
* @param {Number} overage Accept up to this many additional characters for a better result
* @param {String} [ellipsis='…'] The string to append when truncating
* @param {Boolean} [break_line=true] If true, attempt to break at the first LF
* @param {Boolean} [trim=true] If true, runs trim() on the string before truncating
* @returns {String} The truncated string
*/
export function truncate(str, target = 100, overage = 15, ellipsis = '…', break_line = true, trim = true) {
if ( ! str || ! str.length )
return str;
if ( trim )
str = str.trim();
let idx = break_line ? str.indexOf('\n') : -1;
if ( idx === -1 || idx > target )
idx = target;
if ( str.length <= idx )
return str;
let out = str.slice(0, idx).trimRight();
if ( overage > 0 && out.length >= idx ) {
let next_space = str.slice(idx).search(/\s+/);
if ( next_space === -1 && overage + idx > str.length )
next_space = str.length - idx;
if ( next_space !== -1 && next_space <= overage ) {
if ( str.length <= (idx + next_space) )
return str;
out = str.slice(0, idx + next_space);
}
}
return out + ellipsis;
}
export class SourcedSet {
constructor() {
this._cache = [];

View file

@ -0,0 +1,946 @@
'use strict';
// ============================================================================
// Rich Content Tokens
// ============================================================================
import {has} from 'utilities/object';
export const TOKEN_TYPES = {};
const validate = (input, valid) => valid.includes(input) ? input : null;
const VALID_WEIGHTS = ['regular', 'bold', 'semibold'],
VALID_COLORS = ['base', 'alt', 'alt-2', 'link'],
VALID_SIZES = ['1', '2,' ,'3','4','5','6','7','8'],
VALID_WRAPS = ['nowrap', 'pre-wrap'],
VALID_PADDING = {
small: '05',
normal: 'normal',
large: 'large',
huge: 'huge'
};
// ============================================================================
// Render Tokens
// ============================================================================
function applySpacing(term, token, classes, styles) {
for(const mode of ['', '-x','-y','-t','-r','-b','-l']) {
const key = `${term}${mode}`,
value = token[key];
if ( value ) {
if ( VALID_PADDING[value] )
classes.push(`tw-${term}${mode}-${VALID_PADDING[value]}`);
else if ( styles ) {
const thing = term === 'pd' ? 'padding' : 'margin';
if ( mode === '' )
styles[thing] = value;
if ( mode === 'x' || mode === 'l' )
styles[`${thing}-left`] = value;
if ( mode === 'x' || mode === 'r' )
styles[`${thing}-right`] = value;
if ( mode === 'y' || mode === 't' )
styles[`${thing}-top`] = value;
if ( mode === 'y' || mode === 'b' )
styles[`${thing}-bottom`] = value;
}
}
}
}
export function getRoundClass(value) {
let klass;
if ( value === -1 )
klass = 'rounded';
else if ( value === 1 )
klass = 'small';
else if ( value === 2 )
klass = 'medium';
else if ( value === 3 )
klass = 'large';
return klass ? `tw-border-radius-${klass}` : '';
}
// TODO: Mess with this more.
// (It's a function for wrapping React's createElement in a function
// that accepts the same input as Vue's createElement, letting us
// deduplicate a ton of code in here.)
/*export function wrapReactCreate(createElement) {
return (tag, opts, children) => {
if ( typeof tag !== 'string' )
throw new Error('invalid tag');
if ( opts ) {
if ( opts.class ) {
if ( typeof opts.class === 'string' )
opts.className = opts.class;
else if ( Array.isArray(opts.class) )
opts.className = opts.class.join(' ');
else if ( typeof opts.class === 'object' ) {
const bits = [];
for(const [key, val] of Object.entries(opts.class))
if ( val )
bits.push(key);
opts.className = bits.join(' ');
}
opts.class = undefined;
}
if ( opts.attrs ) {
for(const [key, val] of Object.entries(opts.attrs) )
opts[key] = val;
opts.attrs = undefined;
}
if ( opts.props )
throw new Error('props unsupported');
if ( opts.domProps )
throw new Error('domProps unsupported');
if ( opts.nativeOn )
throw new Error('nativeOn unsupported');
if ( opts.on ) {
for(const [key, val] of Object.entries(opts.on) )
opts[`on${key.charAt(0).toUpperCase()}${key.slice(1)}`] = val;
opts.on = undefined;
}
if ( opts.style && typeof opts.style !== 'object' )
opts.style = undefined;
}
return createElement(tag, opts, children);
}
}*/
export function renderWithCapture(tokens, createElement, ctx) {
const old_capture = ctx.text_capture;
ctx.text_capture = [];
const content = renderTokens(tokens, createElement, ctx);
let title = ctx.text_capture.join('').trim();
if ( ! title.length )
title = null;
ctx.text_capture = old_capture;
return {
content,
title
}
}
export function renderTokens(tokens, createElement, ctx) {
if ( tokens == null )
return null;
let out = [];
if ( ! Array.isArray(tokens) )
tokens = [tokens];
for(const token of tokens) {
if ( token == null )
continue;
else if ( Array.isArray(token) )
out = out.concat(renderTokens(token, createElement, ctx));
else if ( typeof token !== 'object' ) {
const val = String(token);
if ( ctx.text_capture )
ctx.text_capture.push(val);
out.push(val);
}
else {
const type = token.type,
handler = TOKEN_TYPES[type];
if ( ! handler ) {
console.warn('Skipping unknown token type', type, token);
continue;
}
const result = handler(token, createElement, ctx);
if ( Array.isArray(result) )
out = out.concat(result);
else if ( result )
out.push(result);
}
}
if ( ! out.length )
return null;
return out;
}
export default renderTokens;
// ============================================================================
// Token Type: Box
// ============================================================================
TOKEN_TYPES.box = function(token, createElement, ctx) {
const classes = [], style = {};
if ( VALID_WRAPS.includes(token.wrap) )
classes.push(`tw-white-space-${token.wrap}`);
if ( token.ellipsis )
classes.push('tw-ellipsis');
if ( token.lines ) {
classes.push('ffz--line-clamp');
style['--ffz-lines'] = token.lines;
}
applySpacing('pd', token, classes, style);
applySpacing('mg', token, classes, style);
const capture = token.ellipsis || token.lines;
let content, title = null;
if ( capture ) {
const out = renderWithCapture(token.content, createElement, ctx);
content = out.content; title = out.title;
} else
content = renderTokens(token.content, createElement, ctx);
if ( ctx.vue )
return createElement('div', {class: classes, style, attrs: {title}}, content);
return createElement('div', {className: classes.join(' '), style, title}, content);
}
// ============================================================================
// Token Type: Conditional
// ============================================================================
TOKEN_TYPES.conditional = function(token, createElement, ctx) {
let passed = true;
if ( has(token, 'media') && token.media != ctx.allow_media )
passed = false;
if ( token.nsfw && ! ctx.allow_unsafe )
passed = false;
if ( passed )
return renderTokens(token.content, createElement, ctx);
return renderTokens(token.alternative, createElement, ctx);
}
// ============================================================================
// Token Type: Fieldset
// ============================================================================
TOKEN_TYPES.fieldset = function(token, createElement, ctx) {
if ( ! Array.isArray(token.fields) )
return null;
const fields = [];
for(const field of token.fields) {
if ( ! field )
continue;
const name = renderTokens(field.name, createElement, ctx),
value = renderTokens(field.value, createElement, ctx);
if ( name == null || value == null )
continue;
if ( ctx.vue )
fields.push(createElement('div', {
class: [
'ffz--field',
field.inline ? 'ffz--field-inline' : false
]
}, [
createElement('div', {
class: 'ffz--field__name tw-semibold'
}, name),
createElement('div', {
class: 'ffz--field__value tw-c-text-alt'
}, value)
]));
else
fields.push(createElement('div', {
className: `ffz--field ${field.inline ? 'ffz--field-inline' : ''}`
}, [
createElement('div', {className: 'ffz--field__name tw-semibold'}, name),
createElement('div', {className: 'ffz--field__value tw-c-text-alt'}, value)
]));
}
if ( ! fields.length )
return null;
if ( ctx.vue )
return createElement('div', {
class: 'ffz--fields'
}, fields);
return createElement('div', {
className: 'ffz--fields'
}, fields);
}
// ============================================================================
// Token Type: Flex
// ============================================================================
const ALIGNMENTS = ['start', 'end', 'center', 'between', 'around'];
TOKEN_TYPES.flex = function(token, createElement, ctx) {
const classes = [], style = {};
if ( token.inline )
classes.push('tw-flex-inline');
else
classes.push('tw-flex');
const overflow = validate(token.overflow, ['hidden', 'auto']);
if ( overflow )
classes.push(`tw-overflow-${overflow}`);
const direction = validate(token.direction, ['column', 'row', 'column-reverse', 'row-reverse']);
if ( direction )
classes.push(`tw-flex-${direction}`);
const wrap = validate(token.wrap, ['wrap', 'nowrap', 'wrap-reverse']);
if ( wrap )
classes.push(`tw-flex-${wrap}`);
let align = validate(token['align-content'], ALIGNMENTS)
if ( align )
classes.push(`tw-align-content-${align}`);
align = validate(token['justify-content'], ALIGNMENTS);
if ( align )
classes.push(`tw-justify-content-${align}`);
align = validate(token['align-items'], ALIGNMENTS)
if ( align )
classes.push(`tw-align-items-${align}`);
align = validate(token['align-self'], ALIGNMENTS)
if ( align )
classes.push(`tw-align-self-${align}`);
applySpacing('pd', token, classes, style);
applySpacing('mg', token, classes, style);
const content = renderTokens(token.content, createElement, ctx);
if ( ctx.vue )
return createElement('div', {class: classes, style}, content);
return createElement('div', {className: classes.join(' '), style}, content);
}
// ============================================================================
// Token Type: Format
// ============================================================================
TOKEN_TYPES.format = function(token, createElement, ctx) {
const type = token.format, val = token.value, opt = token.options;
let out;
if ( type === 'date' )
out = ctx.i18n.formatDate(val, opt);
else if ( type === 'time' )
out = ctx.i18n.formatTime(val, opt);
else if ( type === 'datetime' )
out = ctx.i18n.formatDateTime(val, opt)
else if ( type === 'relative' )
out = ctx.i18n.toRelativeTime(val, opt);
else if ( type === 'duration' )
out = ctx.i18n.formatDuration(val, opt);
else if ( type === 'number' )
out = ctx.i18n.formatNumber(val, opt);
else {
console.warn('Unknown format type:', type);
out = String(val);
}
if ( ctx.text_capture )
ctx.text_capture.push(out);
return out;
}
// ============================================================================
// Token Type: Gallery
// ============================================================================
TOKEN_TYPES.gallery = function(token, createElement, ctx) {
if ( ! token.items )
return null;
let items = token.items.map(item => renderTokens(item, createElement, ctx)).filter(x => x);
if ( ! items.length )
return null;
if ( items.length > 4 )
items = items.slice(0, 4);
const divisions = [],
count = items.length < 4 ? 1 : 2;
divisions.push(ctx.vue ?
createElement('div', {
class: 'ffz--gallery-column',
attrs: {
'data-items': count
}
}, items.slice(0, count)) :
createElement('div', {
className: 'ffz--gallery-column',
'data-items': count
}, items.slice(0, count))
);
if ( items.length > 1 )
divisions.push(ctx.vue ?
createElement('div', {
class: 'ffz--gallery-column',
attrs: {
'data-items': items.length - count
}
}, items.slice(count)) :
createElement('div', {
className: 'ffz--gallery-column',
'data-items': items.length - count
}, items.slice(count))
);
if ( ctx.vue )
return createElement('div', {
class: 'ffz--rich-gallery',
attrs: {
'data-items': items.length
}
}, divisions);
return createElement('div', {
className: 'ffz--rich-gallery',
'data-items': items.length
}, divisions);
}
// ============================================================================
// Token Type: Heading
// ============================================================================
function header_vue(token, h, ctx) {
let content = [];
if ( token.title ) {
const out = renderWithCapture(token.title, h, ctx);
content.push(h('div', {
class: 'tw-ellipsis tw-semibold tw-mg-x-05',
attrs: {
title: out.title
}
}, out.content));
}
if ( token.subtitle ) {
const out = renderWithCapture(token.subtitle, h, ctx);
content.push(h('div', {
class: 'tw-ellipsis tw-c-text-alt-2 tw-mg-x-05',
attrs: {
title: out.title
}
}, out.content));
}
if ( token.extra ) {
const out = renderWithCapture(token.extra, h, ctx);
content.push(h('div', {
class: 'tw-ellipsis tw-c-text-alt-2 tw-mg-x-05',
attrs: {
title: out.title
}
}, out.content));
}
content = h('div', {
class: [
'tw-flex tw-full-width tw-overflow-hidden',
token.compact ? 'ffz--rich-header ffz--compact-header tw-align-items-center' : 'tw-justify-content-center tw-flex-column tw-flex-grow-1'
]
}, content);
if ( token.image ) {
const aspect = token.image.aspect;
let image = render_image({
...token.image,
aspect: undefined
}, h, ctx);
const right = token.image_side === 'right';
if ( image ) {
image = h('div', {
class: [
'ffz--header-image tw-flex-shrink-0 tw-mg-x-05',
aspect ? 'ffz--header-aspect' : null
],
style: {
width: aspect ? `${aspect * (token.compact ? 2.4 : 4.8)}rem` : null
}
}, [image]);
if ( token.compact ) {
if ( right )
content.children.push(image);
else
content.children.unshift(image);
} else {
content = h('div', {
class: 'tw-flex ffz--rich-header'
}, [
right ? content : null,
image,
right ? null : content
])
}
}
}
return content;
}
function header_normal(token, createElement, ctx) {
let content = [];
if ( token.title ) {
const out = renderWithCapture(token.title, createElement, ctx);
content.push(createElement('div', {
className: `tw-ellipsis tw-semibold ${token.compact ? 'tw-mg-r-1' : ''}`,
title: out.title
}, out.content));
}
if ( token.subtitle ) {
const out = renderWithCapture(token.subtitle, createElement, ctx);
content.push(createElement('div', {
className: `tw-ellipsis tw-c-text-alt-2`,
title: out.title
}, out.content));
}
if ( token.extra ) {
const out = renderWithCapture(token.extra, createElement, ctx);
content.push(createElement('div', {
className: 'tw-ellipsis tw-c-text-alt-2',
title: out.title
}, out.content));
}
content = createElement('div', {
className: `tw-flex tw-full-width tw-overflow-hidden ${token.compact ? 'ffz--rich-header ffz--compact-header tw-align-items-center' : 'tw-justify-content-center tw-flex-column tw-flex-grow-1'}`
}, content);
if ( token.image ) {
const aspect = token.image.aspect;
let image = render_image({
...token.image,
aspect: undefined
}, createElement, ctx);
const right = token.image_side === 'right';
if ( image ) {
image = createElement('div', {
className: `ffz--header-image tw-flex-shrink-0 tw-mg-x-05${aspect ? ' ffz--header-aspect' : ''}`,
style: {
width: aspect ? `${aspect * (token.compact ? 2.4 : 4.8)}rem` : null
}
}, image);
if ( token.compact ) {
// We need to do some weird pushy stuff~
// This varies if we're running with React or native.
if ( content instanceof Node ) {
if ( right )
content.appendChild(image);
else
content.insertBefore(image, content.firstChild);
} else {
console.warn('Add React support!');
console.log(content);
}
} else {
content = createElement('div', {
className: 'tw-flex ffz--rich-header'
}, [right ? content : null, image, right ? null : content])
}
}
}
return content;
}
TOKEN_TYPES.header = function(token, createElement, ctx) {
if ( ! token.title && ! token.subtitle && ! token.image && ! token.extra )
return null;
return ctx.vue ?
header_vue(token, createElement, ctx) :
header_normal(token, createElement, ctx);
}
// ============================================================================
// Token Type: Icon
// ============================================================================
TOKEN_TYPES.icon = function(token, createElement, ctx) {
if ( ! token.name )
return null;
return ctx.vue ?
createElement('span', {class: `ffz-i-${token.name}`}) :
createElement('span', {className: `ffz-i-${token.name}`});
}
// ============================================================================
// Token Type: Image
// ============================================================================
function render_image(token, createElement, ctx) {
if ( ! token.url || (has(token, 'sfw') && ! token.sfw && ! ctx.allow_unsafe) )
return null;
const round = getRoundClass(token.rounding);
let aspect;
if ( token.aspect )
aspect = token.aspect
else if ( token.height > 0 && token.width > 0 )
aspect = token.width / token.height;
if ( ctx.vue ) {
const stuff = {
class: [
token.class,
round
],
style: {
width: token.width,
height: token.height
},
attrs: {
src: token.url,
title: token.title
}
};
if ( ctx.onload )
stuff.on = {load: ctx.onload};
const image = createElement('img', stuff);
if ( ! aspect )
return image;
return createElement('aspect', {
props: {
ratio: aspect,
align: 'center'
}
}, [image]);
}
const image = createElement('img', {
className: `${token.class || ''} ${round}`,
src: token.url,
title: token.title || '',
onLoad: ctx.onload
});
if ( ! aspect )
return image;
return createElement('div', {
className: 'tw-aspect tw-aspect--align-center'
}, [
createElement('div', {
className: 'tw-aspect__spacer',
style: {
paddingTop: `${100 * (1 / (aspect || 1))}%`
}
}),
image
]);
}
TOKEN_TYPES.image = render_image;
// ============================================================================
// Token Type: I18n
// ============================================================================
TOKEN_TYPES.i18n = function(token, createElement, ctx) {
if ( ! token.phrase ) {
console.warn('Skipping i18n tag with no phrase');
return null;
}
return renderTokens(
ctx.i18n.tList(token.key, token.phrase, token.content),
createElement,
ctx
);
}
// ============================================================================
// Token Type: Link
// ============================================================================
TOKEN_TYPES.link = function(token, createElement, ctx) {
const content = renderTokens(token.content, createElement, ctx);
const klass = [];
if ( token.interactive )
klass.push(`tw-interactable tw-interactable--hover-enabled tw-interactable--alpha tw-interactive`);
if ( token.tooltip !== false )
klass.push('ffz-tooltip');
if ( token.embed )
klass.push(`tw-block tw-border tw-border-radius-large tw-mg-y-05 tw-pd-05`);
if ( token.no_color )
klass.push(`tw-link--inherit`);
if ( ctx.vue )
return createElement('a', {
class: klass,
attrs: {
rel: 'noopener noreferrer',
target: '_blank',
'data-tooltip-type': 'link',
href: token.url
}
}, content);
return createElement('a', {
className: klass.join(' '),
rel: 'noopener noreferrer',
target: '_blank',
'data-tooltip-type': 'link',
href: token.url
}, content);
}
// ============================================================================
// Token Type: Overlay
// ============================================================================
TOKEN_TYPES.overlay = function(token, createElement, ctx) {
const content = renderTokens(token.content, createElement, ctx);
if ( ! content )
return null;
const corners = [];
for(const corner of ['top-left', 'top', 'top-right', 'left', 'center', 'right', 'bottom-left', 'bottom', 'bottom-right']) {
const stuff = renderTokens(token[corner], createElement, ctx);
if ( stuff )
corners.push(ctx.vue ?
createElement('div', {class: `ffz--overlay__bit`, attrs:{'data-side':corner}}, stuff) :
createElement('div', {className: `ffz--overlay__bit`, 'data-side':corner}, stuff)
);
}
if ( ctx.vue )
return createElement('div', {class: 'ffz--overlay'}, [
createElement('div', {class: 'ffz--overlay__content'}, content),
...corners
]);
return createElement('div', {className: 'ffz--overlay'}, [
createElement('div', {className: 'ffz--overlay__content'}, content),
...corners
]);
}
// ============================================================================
// Token Type: Style
// ============================================================================
TOKEN_TYPES.style = function(token, createElement, ctx) {
const classes = [], style = {};
if ( token.weight ) {
if ( VALID_WEIGHTS.includes(token.weight) )
classes.push(`tw-${token.weight}`);
else
style.weight = token.weight;
}
if ( token.italic )
classes.push('tw-italic');
if ( token.strike )
classes.push('tw-strikethrough');
if ( token.underline )
classes.push('tw-underline');
if ( token.tabular )
classes.push('tw-tabular-nums');
if ( token.size ) {
if ( typeof token.size === 'string' ) {
if ( VALID_SIZES.includes(token.size) )
classes.push(`tw-font-size-${token.size}`);
else
style.fontSize = token.size;
} else
style.fontSize = `${token.size}px`;
}
if ( token.color ) {
if ( VALID_COLORS.includes(token.color) )
classes.push(`tw-c-text-${token.color}`);
else
style.color = token.color;
}
if ( VALID_WRAPS.includes(token.wrap) )
classes.push(`tw-white-space-${token.wrap}`);
if ( token.ellipsis )
classes.push('tw-ellipsis');
applySpacing('pd', token, classes, style);
applySpacing('mg', token, classes, style);
const capture = token.ellipsis;
let content, title = null;
if ( capture ) {
const out = renderWithCapture(token.content, createElement, ctx);
content = out.content; title = out.title;
} else
content = renderTokens(token.content, createElement, ctx);
if ( ctx.vue )
return createElement('span', {class: classes, style, attrs: {title}}, content);
return createElement('span', {className: classes.join(' '), style, title}, content);
}
// ============================================================================
// Token Type: Tag (Deprecated)
// ============================================================================
export const ALLOWED_TAGS = [
'a', 'abbr', 'article', 'aside', 'audio', 'b', 'bdi', 'bdo', 'blockquote', 'br',
'caption', 'code', 'col', 'colgroup', 'data', 'dd', 'div', 'dl', 'dt', 'em',
'figcaption', 'figure', 'footer', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'header',
'hr', 'i', 'img', 'li', 'main', 'nav', 'ol', 'p', 'picture', 'pre', 's', 'section',
'source', 'span', 'strong', 'sub', 'sup', 'table', 'tbody', 'td', 'tfoot',
'th', 'thead', 'time', 'tr', 'track', 'u', 'ul', 'video', 'wbr'
];
export const ALLOWED_ATTRS = {
a: ['href'],
audio: ['autoplay', 'controls', 'loop', 'muted', 'preload', 'src'],
bdo: ['dir'],
col: ['span'],
colgroup: ['span'],
data: ['value'],
img: ['alt', 'height', 'sizes', 'src', 'srcset', 'width'],
source: ['src', 'srcset', 'type', 'media', 'sizes'],
td: ['colspan', 'headers', 'rowspan'],
th: ['abbr', 'colspan', 'headers', 'rowspan', 'scope'],
time: ['datetime'],
track: ['default', 'kind', 'label', 'src', 'srclang'],
video: ['autoplay', 'controls', 'height', 'loop', 'muted', 'poster', 'preload', 'src', 'width'],
};
export const PROPS = [
'muted'
];
export const GLOBAL_ATTRS = ['style', 'title'];
TOKEN_TYPES.tag = function(token, createElement, ctx) {
const tag = String(token.tag || 'span').toLowerCase();
if ( ! ALLOWED_TAGS.includes(tag) ) {
console.warn('Skipping disallowed tag:', tag);
return null;
}
const attrs = {}, props = {};
if ( token.attrs ) {
const allowed = ALLOWED_ATTRS[tag] || [];
for(const [key, val] of Object.entries(token.attrs)) {
if ( ! allowed.includes(key) && ! key.startsWith('data-') && ! GLOBAL_ATTRS.includes(key) )
console.warn(`Skipping disallowed attribute for tag ${tag}:`, key);
else if ( ctx.vue && PROPS.includes(key) )
props[key] = val;
else
attrs[key] = val;
}
}
if ( tag === 'img' || tag === 'picture' )
attrs.onload = ctx.onload;
if ( tag === 'video' || tag === 'audio' )
attrs.loadedmetadata = ctx.onload;
const content = renderTokens(token.content, createElement, ctx);
if ( ctx.vue )
return createElement(tag, {
class: token.class || '',
domProps: props,
attrs
}, content);
return createElement(tag, {
...attrs,
className: token.class || ''
}, content);
}

View file

@ -42,10 +42,14 @@ export class Tooltip {
this.options = Object.assign({}, DefaultOptions, options);
this.live = this.options.live;
this.check_modifiers = this.options.check_modifiers;
this.parent = parent;
this.cls = cls;
if ( this.check_modifiers )
this.installModifiers();
if ( ! this.live ) {
if ( typeof cls === 'string' )
this.elements = parent.querySelectorAll(cls);
@ -65,16 +69,18 @@ export class Tooltip {
this._accessor = `_ffz_tooltip$${last_id++}`;
this._onMouseOut = e => this._exit(e.target);
this._onMouseOut = e => e.target && e.target.dataset.forceOpen !== 'true' && this._exit(e.target);
if ( this.options.manual ) {
// Do nothing~!
} else if ( this.live ) {
this._onMouseOver = e => {
this.updateShift(e.shiftKey);
const target = e.target;
if ( target && target.classList && target.classList.contains(this.cls) )
if ( target && target.classList && target.classList.contains(this.cls) && target.dataset.forceOpen !== 'true' ) {
this._enter(target);
}
};
parent.addEventListener('mouseover', this._onMouseOver);
@ -82,9 +88,11 @@ export class Tooltip {
} else {
this._onMouseOver = e => {
this.updateShift(e.shiftKey);
const target = e.target;
if ( this.elements.has(target) )
if ( this.elements.has(target) && target.dataset.forceOpen !== 'true' ) {
this._enter(e.target);
}
}
if ( this.elements.size <= 5 )
@ -102,6 +110,8 @@ export class Tooltip {
}
destroy() {
this.removeModifiers();
if ( this.options.manual ) {
// Do nothing~!
} else if ( this.live || this.elements.size > 5 ) {
@ -128,6 +138,43 @@ export class Tooltip {
}
installModifiers() {
if ( this._keyUpdate )
return;
this._keyUpdate = e => this.updateShift(e.shiftKey);
window.addEventListener('keydown', this._keyUpdate);
window.addEventListener('keyup', this._keyUpdate);
}
removeModifiers() {
if ( ! this._keyUpdate )
return;
window.removeEventListener('keydown', this._keyUpdate);
window.removeEventListener('keyup', this._keyUpdate);
this._keyUpdate = null;
}
updateShift(state) {
if ( state === this.shift_state )
return;
this.shift_state = state;
if ( ! this._shift_af )
this._shift_af = requestAnimationFrame(() => {
this._shift_af = null;
for(const el of this.elements) {
const tip = el[this._accessor];
if ( tip && tip.outer ) {
tip.outer.dataset.shift = this.shift_state;
tip.update();
}
}
});
}
cleanup() {
if ( this.options.manual )
return;
@ -238,7 +285,8 @@ export class Tooltip {
inner = tip.element = createElement('div', opts.innerClass),
el = tip.outer = createElement('div', {
className: opts.tooltipClass
className: opts.tooltipClass,
'data-shift': this.shift_state
}, [inner, arrow]);
arrow.setAttribute('x-arrow', true);
@ -259,6 +307,7 @@ export class Tooltip {
if ( ! opts.manual || (hover_events && (opts.onHover || opts.onLeave || opts.onMove)) ) {
if ( hover_events && opts.onMove )
el.addEventListener('mousemove', el._ffz_move_handler = event => {
this.updateShift(event.shiftKey);
opts.onMove(target, tip, event);
});
@ -273,7 +322,7 @@ export class Tooltip {
/* no-op */
} else if ( maybe_call(opts.interactive, null, target, tip) )
this._enter(target);
else
else if ( target.dataset.forceOpen !== 'true' )
this._exit(target);
});
@ -281,7 +330,7 @@ export class Tooltip {
if ( hover_events && opts.onLeave )
opts.onLeave(target, tip, event);
if ( ! opts.manual )
if ( ! opts.manual && target.dataset.forceOpen !== 'true' )
this._exit(target);
});
}

View file

@ -50,8 +50,15 @@ export const DEFAULT_TYPES = {
},
number(val, node) {
if ( typeof val !== 'number' )
return val;
if ( typeof val !== 'number' ) {
let new_val = parseInt(val, 10);
if ( isNaN(new_val) || ! isFinite(new_val) )
new_val = parseFloat(val);
if ( isNaN(new_val) || ! isFinite(new_val) )
return val;
val = new_val;
}
return this.formatNumber(val, node.f);
},
@ -105,6 +112,14 @@ export const DEFAULT_FORMATS = {
year: '2-digit'
},
default: {},
medium: {
month: 'short',
day: 'numeric',
year: 'numeric'
},
long: {
month: 'long',
day: 'numeric',
@ -131,13 +146,6 @@ export const DEFAULT_FORMATS = {
second: 'numeric'
},
long: {
hour: 'numeric',
minute: 'numeric',
second: 'numeric',
timeZoneName: 'short'
},
full: {
hour: 'numeric',
minute: 'numeric',
@ -155,14 +163,7 @@ export const DEFAULT_FORMATS = {
minute: 'numeric'
},
medium: {
month: 'numeric',
day: 'numeric',
year: '2-digit',
hour: 'numeric',
minute: 'numeric',
second: 'numeric'
},
medium: {},
long: {
month: 'long',
@ -201,6 +202,10 @@ export default class TranslationCore {
this.defaultLocale = options.defaultLocale || this._locale;
this.transformation = null;
this.defaultDateFormat = options.defaultDateFormat;
this.defaultTimeFormat = options.defaultTimeFormat;
this.defaultDateTimeFormat = options.defaultDateTimeFormat;
this.phrases = new Map;
this.cache = new Map;
@ -235,15 +240,14 @@ export default class TranslationCore {
return thing;
}
formatRelativeTime(value) { // eslint-disable-line class-methods-use-this
if ( !(value instanceof Date) )
value = new Date(Date.now() + value * 1000);
formatRelativeTime(value, f) { // eslint-disable-line class-methods-use-this
const d = dayjs(value),
without_suffix = f === 'plain';
const d = dayjs(value);
try {
return d.locale(this._locale).fromNow(true);
return d.locale(this._locale).fromNow(without_suffix);
} catch(err) {
return d.fromNow(true);
return d.fromNow(without_suffix);
}
}
@ -262,13 +266,15 @@ export default class TranslationCore {
}
formatDate(value, format) {
if ( typeof format === 'string' && format.startsWith('::') ) {
const f = format.substr(2),
d = dayjs(value);
if ( ! format )
format = this.defaultDateFormat;
if ( format && ! this.formats.date[format] ) {
const d = dayjs(value);
try {
return d.locale(this._locale).format(f);
return d.locale(this._locale).format(format);
} catch(err) {
return d.format(f);
return d.format(format);
}
}
@ -279,13 +285,15 @@ export default class TranslationCore {
}
formatTime(value, format) {
if ( typeof format === 'string' && format.startsWith('::') ) {
const f = format.substr(2),
d = dayjs(value);
if ( ! format )
format = this.defaultTimeFormat;
if ( format && ! this.formats.time[format] ) {
const d = dayjs(value);
try {
return d.locale(this._locale).format(f);
return d.locale(this._locale).format(format);
} catch(err) {
return d.format(f);
return d.format(format);
}
}
@ -296,13 +304,15 @@ export default class TranslationCore {
}
formatDateTime(value, format) {
if ( typeof format === 'string' && format.startsWith('::') ) {
const f = format.substr(2),
d = dayjs(value);
if ( ! format )
format = this.defaultDateTimeFormat;
if ( format && ! this.formats.datetime[format] ) {
const d = dayjs(value);
try {
return d.locale(this._locale).format(f);
return d.locale(this._locale).format(format);
} catch(err) {
return d.format(f);
return d.format(format);
}
}

View file

@ -194,6 +194,9 @@ export class Vue extends Module {
const router = t.resolve('site.router');
return router.getURL(route, data, opts, ...args);
},
getI18n() {
return t.i18n;
},
t(key, phrase, options) {
return this.$i18n.t_(key, phrase, options);
},