mirror of
https://github.com/BetterSEQTA/BetterSEQTA-Plus.git
synced 2026-06-06 03:34:40 +00:00
feat: add experimental better editor
This commit is contained in:
@@ -35,6 +35,9 @@
|
|||||||
"url": "^0.11.3"
|
"url": "^0.11.3"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@blocknote/core": "^0.14.1",
|
||||||
|
"@blocknote/mantine": "^0.14.1",
|
||||||
|
"@blocknote/react": "^0.14.1",
|
||||||
"@codemirror/lang-less": "^6.0.2",
|
"@codemirror/lang-less": "^6.0.2",
|
||||||
"@heroicons/react": "^2.1.3",
|
"@heroicons/react": "^2.1.3",
|
||||||
"@million/lint": "latest",
|
"@million/lint": "latest",
|
||||||
|
|||||||
+6
-3
@@ -27,6 +27,7 @@ import { initializeSettingsState, settingsState } from './seqta/utils/listeners/
|
|||||||
import { StorageChangeHandler } from './seqta/utils/listeners/StorageChanges'
|
import { StorageChangeHandler } from './seqta/utils/listeners/StorageChanges'
|
||||||
import { AddBetterSEQTAElements } from './seqta/ui/AddBetterSEQTAElements'
|
import { AddBetterSEQTAElements } from './seqta/ui/AddBetterSEQTAElements'
|
||||||
import { eventManager } from './seqta/utils/listeners/EventManager'
|
import { eventManager } from './seqta/utils/listeners/EventManager'
|
||||||
|
import handleComposeMessage from './seqta/ui/customMessageEditor'
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
@@ -65,9 +66,6 @@ async function init() {
|
|||||||
|
|
||||||
if (settingsState.onoff) {
|
if (settingsState.onoff) {
|
||||||
enableCurrentTheme()
|
enableCurrentTheme()
|
||||||
//console.log(await browser.storage.local.get())
|
|
||||||
//settingsState.bksliderinput = '10'
|
|
||||||
//console.log(await browser.storage.local.get())
|
|
||||||
|
|
||||||
// TEMP FIX for bug! -> this is a hack to get the injected.css file to have HMR in development mode as this import system is currently broken with crxjs
|
// TEMP FIX for bug! -> this is a hack to get the injected.css file to have HMR in development mode as this import system is currently broken with crxjs
|
||||||
if (import.meta.env.MODE === 'development') {
|
if (import.meta.env.MODE === 'development') {
|
||||||
@@ -563,6 +561,11 @@ async function LoadPageElements(): Promise<void> {
|
|||||||
className: 'timetablepage',
|
className: 'timetablepage',
|
||||||
}, handleTimetable);
|
}, handleTimetable);
|
||||||
|
|
||||||
|
eventManager.register('composeMessage', {
|
||||||
|
elementType: 'div',
|
||||||
|
customCheck: (element: Element) => element.querySelector('.coneqtMessage') !== null
|
||||||
|
}, handleComposeMessage);
|
||||||
|
|
||||||
await handleSublink(sublink);
|
await handleSublink(sublink);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,14 @@ html {
|
|||||||
background-color 200ms ease-in-out,
|
background-color 200ms ease-in-out,
|
||||||
backdrop-filter 200ms ease-in-out;
|
backdrop-filter 200ms ease-in-out;
|
||||||
}
|
}
|
||||||
|
.extension-editor {
|
||||||
|
background: var(--background-primary);
|
||||||
|
border-radius: 16px;
|
||||||
|
border: none !important;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
visibility: visible !important;
|
||||||
|
}
|
||||||
#themeCreatorIframe {
|
#themeCreatorIframe {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
right: 0;
|
right: 0;
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
const METHOD = {
|
||||||
|
GET: 'get',
|
||||||
|
DELETE: 'delete',
|
||||||
|
POST: 'post',
|
||||||
|
PUT: 'put',
|
||||||
|
} as const
|
||||||
|
|
||||||
|
type ObjectValues<T> = T[keyof T]
|
||||||
|
type Method = ObjectValues<typeof METHOD>
|
||||||
|
|
||||||
|
export default Method
|
||||||
@@ -0,0 +1,329 @@
|
|||||||
|
import { ResponseFn } from './sbFetch'
|
||||||
|
|
||||||
|
export interface ISbStoriesParams
|
||||||
|
extends Partial<ISbStoryData>,
|
||||||
|
ISbMultipleStoriesData {
|
||||||
|
resolve_level?: number
|
||||||
|
_stopResolving?: boolean
|
||||||
|
by_slugs?: string
|
||||||
|
by_uuids?: string
|
||||||
|
by_uuids_ordered?: string
|
||||||
|
component?: string
|
||||||
|
content_type?: string
|
||||||
|
cv?: number
|
||||||
|
datasource?: string
|
||||||
|
dimension?: string
|
||||||
|
excluding_fields?: string
|
||||||
|
excluding_ids?: string
|
||||||
|
excluding_slugs?: string
|
||||||
|
fallback_lang?: string
|
||||||
|
filename?: string
|
||||||
|
filter_query?: any
|
||||||
|
first_published_at_gt?: string
|
||||||
|
first_published_at_lt?: string
|
||||||
|
from_release?: string
|
||||||
|
is_startpage?: boolean
|
||||||
|
language?: string
|
||||||
|
level?: number
|
||||||
|
page?: number
|
||||||
|
per_page?: number
|
||||||
|
published_at_gt?: string
|
||||||
|
published_at_lt?: string
|
||||||
|
resolve_assets?: number
|
||||||
|
resolve_links?: 'link' | 'url' | 'story' | '0' | '1' | 'link'
|
||||||
|
resolve_relations?: string | string[]
|
||||||
|
search_term?: string
|
||||||
|
size?: string
|
||||||
|
sort_by?: string
|
||||||
|
starts_with?: string
|
||||||
|
token?: string
|
||||||
|
version?: 'draft' | 'published'
|
||||||
|
with_tag?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ISbStoryParams {
|
||||||
|
resolve_level?: number
|
||||||
|
token?: string
|
||||||
|
find_by?: 'uuid'
|
||||||
|
version?: 'draft' | 'published'
|
||||||
|
resolve_links?: 'link' | 'url' | 'story' | '0' | '1'
|
||||||
|
resolve_relations?: string | string[]
|
||||||
|
cv?: number
|
||||||
|
from_release?: string
|
||||||
|
language?: string
|
||||||
|
fallback_lang?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ISbComponentType<T extends string> {
|
||||||
|
_uid?: string
|
||||||
|
component?: T
|
||||||
|
_editable?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ISbStoryData<
|
||||||
|
Content = ISbComponentType<string> & { [index: string]: any },
|
||||||
|
> extends ISbMultipleStoriesData {
|
||||||
|
alternates: ISbAlternateObject[]
|
||||||
|
breadcrumbs?: ISbLinkURLObject[]
|
||||||
|
content: Content
|
||||||
|
created_at: string
|
||||||
|
default_full_slug?: string
|
||||||
|
default_root?: string
|
||||||
|
disble_fe_editor?: boolean
|
||||||
|
first_published_at?: string
|
||||||
|
full_slug: string
|
||||||
|
group_id: string
|
||||||
|
id: number
|
||||||
|
imported_at?: string
|
||||||
|
is_folder?: boolean
|
||||||
|
is_startpage?: boolean
|
||||||
|
lang: string
|
||||||
|
last_author?: {
|
||||||
|
id: number
|
||||||
|
userid: string
|
||||||
|
}
|
||||||
|
meta_data: any
|
||||||
|
name: string
|
||||||
|
parent?: ISbStoryData
|
||||||
|
parent_id: number
|
||||||
|
path?: string
|
||||||
|
pinned?: '1' | boolean
|
||||||
|
position: number
|
||||||
|
published?: boolean
|
||||||
|
published_at: string | null
|
||||||
|
release_id?: number
|
||||||
|
slug: string
|
||||||
|
sort_by_date: string | null
|
||||||
|
tag_list: string[]
|
||||||
|
translated_slugs?: {
|
||||||
|
path: string
|
||||||
|
name: string | null
|
||||||
|
lang: ISbStoryData['lang']
|
||||||
|
}[]
|
||||||
|
unpublished_changes?: boolean
|
||||||
|
updated_at?: string
|
||||||
|
uuid: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ISbMultipleStoriesData {
|
||||||
|
by_ids?: string
|
||||||
|
by_uuids?: string
|
||||||
|
contain_component?: string
|
||||||
|
excluding_ids?: string
|
||||||
|
filter_query?: any
|
||||||
|
folder_only?: boolean
|
||||||
|
full_slug?: string
|
||||||
|
in_release?: string
|
||||||
|
in_trash?: boolean
|
||||||
|
is_published?: boolean
|
||||||
|
in_workflow_stages?: string
|
||||||
|
page?: number
|
||||||
|
pinned?: '1' | boolean
|
||||||
|
search?: string
|
||||||
|
sort_by?: string
|
||||||
|
starts_with?: string
|
||||||
|
story_only?: boolean
|
||||||
|
text_search?: string
|
||||||
|
with_parent?: number
|
||||||
|
with_slug?: string
|
||||||
|
with_tag?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ISbAlternateObject {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
slug: string
|
||||||
|
published: boolean
|
||||||
|
full_slug: string
|
||||||
|
is_folder: boolean
|
||||||
|
parent_id: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ISbLinkURLObject {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
slug: string
|
||||||
|
full_slug: string
|
||||||
|
url: string
|
||||||
|
uuid: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ISbStories {
|
||||||
|
data: {
|
||||||
|
cv: number
|
||||||
|
links: (ISbStoryData | ISbLinkURLObject)[]
|
||||||
|
rels: ISbStoryData[]
|
||||||
|
stories: ISbStoryData[]
|
||||||
|
}
|
||||||
|
perPage: number
|
||||||
|
total: number
|
||||||
|
headers: any
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ISbStory {
|
||||||
|
data: {
|
||||||
|
cv: number
|
||||||
|
links: (ISbStoryData | ISbLinkURLObject)[]
|
||||||
|
rels: ISbStoryData[]
|
||||||
|
story: ISbStoryData
|
||||||
|
}
|
||||||
|
headers: any
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IMemoryType extends ISbResult {
|
||||||
|
[key: string]: any
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ICacheProvider {
|
||||||
|
get: (key: string) => Promise<IMemoryType | void>
|
||||||
|
set: (key: string, content: ISbResult) => Promise<void>
|
||||||
|
getAll: () => Promise<IMemoryType | void>
|
||||||
|
flush: () => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ISbCache {
|
||||||
|
type?: 'none' | 'memory' | 'custom'
|
||||||
|
clear?: 'auto' | 'manual'
|
||||||
|
custom?: ICacheProvider
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ISbConfig {
|
||||||
|
accessToken?: string
|
||||||
|
oauthToken?: string
|
||||||
|
resolveNestedRelations?: boolean
|
||||||
|
cache?: ISbCache
|
||||||
|
responseInterceptor?: ResponseFn
|
||||||
|
fetch?: typeof fetch
|
||||||
|
timeout?: number
|
||||||
|
headers?: any
|
||||||
|
region?: string
|
||||||
|
maxRetries?: number
|
||||||
|
https?: boolean
|
||||||
|
rateLimit?: number
|
||||||
|
componentResolver?: (component: string, data: any) => void
|
||||||
|
richTextSchema?: ISbSchema
|
||||||
|
endpoint?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ISbResult {
|
||||||
|
data: any
|
||||||
|
perPage: number
|
||||||
|
total: number
|
||||||
|
headers: Headers
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ISbResponse {
|
||||||
|
data: any
|
||||||
|
status: number
|
||||||
|
statusText: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ISbError {
|
||||||
|
message?: string
|
||||||
|
status?: number
|
||||||
|
response?: ISbResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ISbNode extends Element {
|
||||||
|
content: object[]
|
||||||
|
attrs: {
|
||||||
|
anchor?: string
|
||||||
|
body?: Array<ISbComponentType<any>>
|
||||||
|
href?: string
|
||||||
|
level?: number
|
||||||
|
linktype?: string
|
||||||
|
custom?: LinkCustomAttributes
|
||||||
|
[key: string]: any | undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NodeSchema = {
|
||||||
|
(node: ISbNode): object
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MarkSchema = {
|
||||||
|
(node: ISbNode): object
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ISbContentMangmntAPI<
|
||||||
|
Content = ISbComponentType<string> & { [index: string]: any },
|
||||||
|
> {
|
||||||
|
story: {
|
||||||
|
name: string
|
||||||
|
slug: string
|
||||||
|
content?: Content
|
||||||
|
default_root?: boolean
|
||||||
|
is_folder?: boolean
|
||||||
|
parent_id?: string
|
||||||
|
disble_fe_editor?: boolean
|
||||||
|
path?: string
|
||||||
|
is_startpage?: boolean
|
||||||
|
position?: number
|
||||||
|
first_published_at?: string
|
||||||
|
translated_slugs_attributes?: {
|
||||||
|
path: string
|
||||||
|
name: string | null
|
||||||
|
lang: ISbContentMangmntAPI['lang']
|
||||||
|
}[]
|
||||||
|
}
|
||||||
|
force_update?: '1' | unknown
|
||||||
|
release_id?: number
|
||||||
|
publish?: '1' | unknown
|
||||||
|
lang?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ISbManagmentApiResult {
|
||||||
|
data: any
|
||||||
|
headers: any
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ISbSchema {
|
||||||
|
nodes: any
|
||||||
|
marks: any
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ISbRichtext {
|
||||||
|
content?: ISbRichtext[]
|
||||||
|
marks?: ISbRichtext[]
|
||||||
|
attrs?: any
|
||||||
|
text?: string
|
||||||
|
type: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LinkCustomAttributes {
|
||||||
|
rel?: string
|
||||||
|
title?: string
|
||||||
|
[key: string]: any
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ISbLink {
|
||||||
|
id?: number
|
||||||
|
slug?: string
|
||||||
|
name?: string
|
||||||
|
is_folder?: boolean
|
||||||
|
parent_id?: number
|
||||||
|
published?: boolean
|
||||||
|
position?: number
|
||||||
|
uuid?: string
|
||||||
|
is_startpage?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ISbLinks {
|
||||||
|
links?: {
|
||||||
|
[key: string]: ISbLink
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ThrottleFn = {
|
||||||
|
(...args: any): any
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AsyncFn = (...args: any) => [] | Promise<ISbResult>
|
||||||
|
|
||||||
|
export type ArrayFn = (...args: any) => void
|
||||||
|
|
||||||
|
export type HtmlEscapes = {
|
||||||
|
[key: string]: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ISbCustomFetch extends Omit<RequestInit, 'method'> {}
|
||||||
@@ -0,0 +1,396 @@
|
|||||||
|
import defaultHtmlSerializer from './schema'
|
||||||
|
import { ISbSchema, ISbRichtext } from './interfaces'
|
||||||
|
|
||||||
|
type HtmlEscapes = {
|
||||||
|
[key: string]: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type OptimizeImagesOptions =
|
||||||
|
| boolean
|
||||||
|
| {
|
||||||
|
class?: string
|
||||||
|
filters?: {
|
||||||
|
blur?: number
|
||||||
|
brightness?: number
|
||||||
|
fill?: string
|
||||||
|
format?: 'webp' | 'jpeg' | 'png'
|
||||||
|
grayscale?: boolean
|
||||||
|
quality?: number
|
||||||
|
rotate?: 90 | 180 | 270
|
||||||
|
}
|
||||||
|
height?: number
|
||||||
|
loading?: 'lazy' | 'eager'
|
||||||
|
sizes?: string[]
|
||||||
|
srcset?: (number | [number, number])[]
|
||||||
|
width?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type RenderOptions = {
|
||||||
|
optimizeImages?: OptimizeImagesOptions
|
||||||
|
}
|
||||||
|
|
||||||
|
const escapeHTML = function (string: string) {
|
||||||
|
const htmlEscapes = {
|
||||||
|
'&': '&',
|
||||||
|
'<': '<',
|
||||||
|
'>': '>',
|
||||||
|
'"': '"',
|
||||||
|
"'": ''',
|
||||||
|
} as HtmlEscapes
|
||||||
|
|
||||||
|
const reUnescapedHtml = /[&<>"']/g
|
||||||
|
const reHasUnescapedHtml = RegExp(reUnescapedHtml.source)
|
||||||
|
|
||||||
|
return string && reHasUnescapedHtml.test(string)
|
||||||
|
? string.replace(reUnescapedHtml, (chr) => htmlEscapes[chr])
|
||||||
|
: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ISbTag extends Element {
|
||||||
|
[key: string]: any
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ISbNode {
|
||||||
|
[key: string]: ISbSchema | ((arg: ISbRichtext) => any)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ISbFunction<T extends any[], R> {
|
||||||
|
(...args: T): R
|
||||||
|
}
|
||||||
|
|
||||||
|
class RichTextResolver {
|
||||||
|
private marks: ISbNode
|
||||||
|
private nodes: ISbNode
|
||||||
|
|
||||||
|
public constructor(schema?: ISbSchema) {
|
||||||
|
if (!schema) {
|
||||||
|
schema = defaultHtmlSerializer as ISbSchema
|
||||||
|
}
|
||||||
|
|
||||||
|
this.marks = schema.marks || []
|
||||||
|
this.nodes = schema.nodes || []
|
||||||
|
}
|
||||||
|
|
||||||
|
public addNode(key: string, schema: ISbSchema | ISbFunction<any, any>) {
|
||||||
|
this.nodes[key] = schema
|
||||||
|
}
|
||||||
|
|
||||||
|
public addMark(key: string, schema: ISbSchema) {
|
||||||
|
this.marks[key] = schema
|
||||||
|
}
|
||||||
|
|
||||||
|
public render(
|
||||||
|
data?: ISbRichtext,
|
||||||
|
options: RenderOptions = { optimizeImages: false }
|
||||||
|
) {
|
||||||
|
if (data && data.content && Array.isArray(data.content)) {
|
||||||
|
let html = ''
|
||||||
|
|
||||||
|
data.content.forEach((node: any) => {
|
||||||
|
html += this.renderNode(node)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (options.optimizeImages) {
|
||||||
|
return this.optimizeImages(html, options.optimizeImages)
|
||||||
|
}
|
||||||
|
|
||||||
|
return html
|
||||||
|
}
|
||||||
|
|
||||||
|
console.warn(
|
||||||
|
`The render method must receive an Object with a "content" field.
|
||||||
|
The "content" field must be an array of nodes as the type ISbRichtext.
|
||||||
|
ISbRichtext:
|
||||||
|
content?: ISbRichtext[]
|
||||||
|
marks?: ISbRichtext[]
|
||||||
|
attrs?: any
|
||||||
|
text?: string
|
||||||
|
type: string
|
||||||
|
|
||||||
|
Example:
|
||||||
|
{
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
text: 'Hello World',
|
||||||
|
type: 'text'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
type: 'paragraph'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
type: 'doc'
|
||||||
|
}`
|
||||||
|
)
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
private optimizeImages(html: string, options: OptimizeImagesOptions): string {
|
||||||
|
let w = 0
|
||||||
|
let h = 0
|
||||||
|
let imageAttributes = ''
|
||||||
|
let filters = ''
|
||||||
|
|
||||||
|
if (typeof options !== 'boolean') {
|
||||||
|
if (typeof options.width === 'number' && options.width > 0) {
|
||||||
|
imageAttributes += `width="${options.width}" `
|
||||||
|
w = options.width
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof options.height === 'number' && options.height > 0) {
|
||||||
|
imageAttributes += `height="${options.height}" `
|
||||||
|
h = options.height
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.loading === 'lazy' || options.loading === 'eager') {
|
||||||
|
imageAttributes += `loading="${options.loading}" `
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof options.class === 'string' && options.class.length > 0) {
|
||||||
|
imageAttributes += `class="${options.class}" `
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.filters) {
|
||||||
|
if (
|
||||||
|
typeof options.filters.blur === 'number' &&
|
||||||
|
options.filters.blur >= 0 &&
|
||||||
|
options.filters.blur <= 100
|
||||||
|
) {
|
||||||
|
filters += `:blur(${options.filters.blur})`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof options.filters.brightness === 'number' &&
|
||||||
|
options.filters.brightness >= -100 &&
|
||||||
|
options.filters.brightness <= 100
|
||||||
|
) {
|
||||||
|
filters += `:brightness(${options.filters.brightness})`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
options.filters.fill &&
|
||||||
|
(options.filters.fill.match(/[0-9A-Fa-f]{6}/g) ||
|
||||||
|
options.filters.fill === 'transparent')
|
||||||
|
) {
|
||||||
|
filters += `:fill(${options.filters.fill})`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
options.filters.format &&
|
||||||
|
['webp', 'png', 'jpeg'].includes(options.filters.format)
|
||||||
|
) {
|
||||||
|
filters += `:format(${options.filters.format})`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof options.filters.grayscale === 'boolean' &&
|
||||||
|
options.filters.grayscale
|
||||||
|
) {
|
||||||
|
filters += ':grayscale()'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof options.filters.quality === 'number' &&
|
||||||
|
options.filters.quality >= 0 &&
|
||||||
|
options.filters.quality <= 100
|
||||||
|
) {
|
||||||
|
filters += `:quality(${options.filters.quality})`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
options.filters.rotate &&
|
||||||
|
[90, 180, 270].includes(options.filters.rotate)
|
||||||
|
) {
|
||||||
|
filters += `:rotate(${options.filters.rotate})`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.length > 0) filters = '/filters' + filters
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (imageAttributes.length > 0) {
|
||||||
|
html = html.replace(/<img/g, `<img ${imageAttributes.trim()}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof options !== 'boolean' && (options.sizes || options.srcset)) {
|
||||||
|
html = html.replace(/<img.*?src=["|'](.*?)["|']/g, (value: string) => {
|
||||||
|
const url = value.match(
|
||||||
|
/a.storyblok.com\/f\/(\d+)\/([^.]+)\.(gif|jpg|jpeg|png|tif|tiff|bmp)/g
|
||||||
|
)
|
||||||
|
|
||||||
|
if (url && url.length > 0) {
|
||||||
|
const imageAttributes = {
|
||||||
|
srcset: options.srcset
|
||||||
|
?.map((value) => {
|
||||||
|
if (typeof value === 'number') {
|
||||||
|
return `//${url}/m/${value}x0${filters} ${value}w`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'object' && value.length === 2) {
|
||||||
|
let w = 0
|
||||||
|
let h = 0
|
||||||
|
if (typeof value[0] === 'number') w = value[0]
|
||||||
|
if (typeof value[1] === 'number') h = value[1]
|
||||||
|
return `//${url}/m/${w}x${h}${filters} ${w}w`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.join(', '),
|
||||||
|
sizes: options.sizes?.map((size) => size).join(', '),
|
||||||
|
}
|
||||||
|
|
||||||
|
let renderImageAttributes = ''
|
||||||
|
if (imageAttributes.srcset) {
|
||||||
|
renderImageAttributes += `srcset="${imageAttributes.srcset}" `
|
||||||
|
}
|
||||||
|
if (imageAttributes.sizes) {
|
||||||
|
renderImageAttributes += `sizes="${imageAttributes.sizes}" `
|
||||||
|
}
|
||||||
|
|
||||||
|
return value.replace(/<img/g, `<img ${renderImageAttributes.trim()}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return value
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return html
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderNode(item: ISbRichtext) {
|
||||||
|
const html = []
|
||||||
|
|
||||||
|
if (item.marks) {
|
||||||
|
item.marks.forEach((m: any) => {
|
||||||
|
const mark = this.getMatchingMark(m)
|
||||||
|
|
||||||
|
if (mark && mark.tag !== '') {
|
||||||
|
html.push(this.renderOpeningTag(mark.tag))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const node = this.getMatchingNode(item)
|
||||||
|
|
||||||
|
if (node && node.tag) {
|
||||||
|
html.push(this.renderOpeningTag(node.tag))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.content) {
|
||||||
|
item.content.forEach((content: any) => {
|
||||||
|
html.push(this.renderNode(content))
|
||||||
|
})
|
||||||
|
} else if (item.text) {
|
||||||
|
html.push(escapeHTML(item.text))
|
||||||
|
} else if (node && node.singleTag) {
|
||||||
|
html.push(this.renderTag(node.singleTag, ' /'))
|
||||||
|
} else if (node && node.html) {
|
||||||
|
html.push(node.html)
|
||||||
|
} else if (item.type === 'emoji') {
|
||||||
|
html.push(this.renderEmoji(item))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node && node.tag) {
|
||||||
|
html.push(this.renderClosingTag(node.tag))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.marks) {
|
||||||
|
item.marks
|
||||||
|
.slice(0)
|
||||||
|
.reverse()
|
||||||
|
.forEach((m) => {
|
||||||
|
const mark = this.getMatchingMark(m)
|
||||||
|
|
||||||
|
if (mark && mark.tag !== '') {
|
||||||
|
html.push(this.renderClosingTag(mark.tag))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return html.join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderTag(tags: ISbTag[], ending: string) {
|
||||||
|
if (tags.constructor === String) {
|
||||||
|
return `<${tags}${ending}>`
|
||||||
|
}
|
||||||
|
|
||||||
|
const all = tags.map((tag) => {
|
||||||
|
if (tag.constructor === String) {
|
||||||
|
return `<${tag}${ending}>`
|
||||||
|
} else {
|
||||||
|
let h = `<${tag.tag}`
|
||||||
|
if (tag.attrs) {
|
||||||
|
for (const key in tag.attrs) {
|
||||||
|
const value = tag.attrs[key]
|
||||||
|
if (value !== null) {
|
||||||
|
h += ` ${key}="${value}"`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${h}${ending}>`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return all.join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderOpeningTag(tags: ISbTag[]) {
|
||||||
|
return this.renderTag(tags, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderClosingTag(tags: ISbTag[]) {
|
||||||
|
if (tags.constructor === String) {
|
||||||
|
return `</${tags}>`
|
||||||
|
}
|
||||||
|
|
||||||
|
const all = tags
|
||||||
|
.slice(0)
|
||||||
|
.reverse()
|
||||||
|
.map((tag) => {
|
||||||
|
if (tag.constructor === String) {
|
||||||
|
return `</${tag}>`
|
||||||
|
} else {
|
||||||
|
return `</${tag.tag}>`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return all.join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
private getMatchingNode(item: ISbRichtext) {
|
||||||
|
const node = this.nodes[item.type]
|
||||||
|
if (typeof node === 'function') {
|
||||||
|
return node(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getMatchingMark(item: ISbRichtext) {
|
||||||
|
const node = this.marks[item.type]
|
||||||
|
if (typeof node === 'function') {
|
||||||
|
return node(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderEmoji(item: ISbRichtext) {
|
||||||
|
if (item.attrs.emoji) {
|
||||||
|
return item.attrs.emoji
|
||||||
|
}
|
||||||
|
|
||||||
|
const emojiImageContainer = [
|
||||||
|
{
|
||||||
|
tag: 'img',
|
||||||
|
attrs: {
|
||||||
|
src: item.attrs.fallbackImage,
|
||||||
|
draggable: 'false',
|
||||||
|
loading: 'lazy',
|
||||||
|
align: 'absmiddle',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
] as unknown as ISbTag[]
|
||||||
|
|
||||||
|
return this.renderTag(emojiImageContainer, ' /')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default RichTextResolver
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
import { SbHelpers } from './sbHelpers'
|
||||||
|
|
||||||
|
import {
|
||||||
|
ISbResponse,
|
||||||
|
ISbError,
|
||||||
|
ISbStoriesParams,
|
||||||
|
ISbCustomFetch,
|
||||||
|
} from './interfaces'
|
||||||
|
import Method from './constants'
|
||||||
|
|
||||||
|
export type ResponseFn = {
|
||||||
|
(arg?: ISbResponse | any): any
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ISbFetch {
|
||||||
|
baseURL: string
|
||||||
|
timeout?: number
|
||||||
|
headers: Headers
|
||||||
|
responseInterceptor?: ResponseFn
|
||||||
|
fetch?: typeof fetch
|
||||||
|
}
|
||||||
|
|
||||||
|
class SbFetch {
|
||||||
|
private baseURL: string
|
||||||
|
private timeout?: number
|
||||||
|
private headers: Headers
|
||||||
|
private responseInterceptor?: ResponseFn
|
||||||
|
private fetch: typeof fetch
|
||||||
|
private ejectInterceptor?: boolean
|
||||||
|
private url: string
|
||||||
|
private parameters: ISbStoriesParams
|
||||||
|
private fetchOptions: ISbCustomFetch
|
||||||
|
|
||||||
|
public constructor($c: ISbFetch) {
|
||||||
|
this.baseURL = $c.baseURL
|
||||||
|
this.headers = $c.headers || new Headers()
|
||||||
|
this.timeout = $c?.timeout ? $c.timeout * 1000 : 0
|
||||||
|
this.responseInterceptor = $c.responseInterceptor
|
||||||
|
this.fetch = (...args: [any]) =>
|
||||||
|
$c.fetch ? $c.fetch(...args) : fetch(...args)
|
||||||
|
this.ejectInterceptor = false
|
||||||
|
this.url = ''
|
||||||
|
this.parameters = {} as ISbStoriesParams
|
||||||
|
this.fetchOptions = {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param url string
|
||||||
|
* @param params ISbStoriesParams
|
||||||
|
* @returns Promise<ISbResponse | Error>
|
||||||
|
*/
|
||||||
|
public get(url: string, params: ISbStoriesParams) {
|
||||||
|
this.url = url
|
||||||
|
this.parameters = params
|
||||||
|
return this._methodHandler('get')
|
||||||
|
}
|
||||||
|
|
||||||
|
public post(url: string, params: ISbStoriesParams) {
|
||||||
|
this.url = url
|
||||||
|
this.parameters = params
|
||||||
|
return this._methodHandler('post')
|
||||||
|
}
|
||||||
|
|
||||||
|
public put(url: string, params: ISbStoriesParams) {
|
||||||
|
this.url = url
|
||||||
|
this.parameters = params
|
||||||
|
return this._methodHandler('put')
|
||||||
|
}
|
||||||
|
|
||||||
|
public delete(url: string, params: ISbStoriesParams) {
|
||||||
|
this.url = url
|
||||||
|
this.parameters = params
|
||||||
|
return this._methodHandler('delete')
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _responseHandler(res: Response) {
|
||||||
|
const headers: string[] = []
|
||||||
|
const response = {
|
||||||
|
data: {},
|
||||||
|
headers: {},
|
||||||
|
status: 0,
|
||||||
|
statusText: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.status !== 204) {
|
||||||
|
await res.json().then(($r) => {
|
||||||
|
response.data = $r
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const pair of res.headers.entries()) {
|
||||||
|
headers[pair[0] as any] = pair[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
response.headers = { ...headers }
|
||||||
|
response.status = res.status
|
||||||
|
response.statusText = res.statusText
|
||||||
|
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _methodHandler(
|
||||||
|
method: Method
|
||||||
|
): Promise<ISbResponse | ISbError> {
|
||||||
|
let urlString = `${this.baseURL}${this.url}`
|
||||||
|
|
||||||
|
let body = null
|
||||||
|
|
||||||
|
if (method === 'get') {
|
||||||
|
const helper = new SbHelpers()
|
||||||
|
urlString = `${this.baseURL}${this.url}?${helper.stringify(
|
||||||
|
this.parameters
|
||||||
|
)}`
|
||||||
|
} else {
|
||||||
|
body = JSON.stringify(this.parameters)
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = new URL(urlString)
|
||||||
|
|
||||||
|
const controller = new AbortController()
|
||||||
|
const { signal } = controller
|
||||||
|
|
||||||
|
let timeout
|
||||||
|
|
||||||
|
if (this.timeout) {
|
||||||
|
timeout = setTimeout(() => controller.abort(), this.timeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const fetchResponse = await this.fetch(`${url}`, {
|
||||||
|
method,
|
||||||
|
headers: this.headers,
|
||||||
|
body,
|
||||||
|
signal,
|
||||||
|
...this.fetchOptions,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (this.timeout) {
|
||||||
|
clearTimeout(timeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = (await this._responseHandler(
|
||||||
|
fetchResponse
|
||||||
|
)) as ISbResponse
|
||||||
|
|
||||||
|
if (this.responseInterceptor && !this.ejectInterceptor) {
|
||||||
|
return this._statusHandler(this.responseInterceptor(response))
|
||||||
|
} else {
|
||||||
|
return this._statusHandler(response)
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
const error: ISbError = {
|
||||||
|
message: err,
|
||||||
|
}
|
||||||
|
return error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public setFetchOptions(fetchOptions: ISbCustomFetch = {}) {
|
||||||
|
if (Object.keys(fetchOptions).length > 0 && 'method' in fetchOptions) {
|
||||||
|
delete fetchOptions.method
|
||||||
|
}
|
||||||
|
this.fetchOptions = { ...fetchOptions }
|
||||||
|
}
|
||||||
|
|
||||||
|
public eject() {
|
||||||
|
this.ejectInterceptor = true
|
||||||
|
}
|
||||||
|
|
||||||
|
private _statusHandler(res: ISbResponse): Promise<ISbResponse | ISbError> {
|
||||||
|
const statusOk = /20[0-6]/g
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (statusOk.test(`${res.status}`)) {
|
||||||
|
return resolve(res)
|
||||||
|
}
|
||||||
|
|
||||||
|
const error: ISbError = {
|
||||||
|
message: res.statusText,
|
||||||
|
status: res.status,
|
||||||
|
response: Array.isArray(res.data)
|
||||||
|
? res.data[0]
|
||||||
|
: res.data.error || res.data.slug,
|
||||||
|
}
|
||||||
|
|
||||||
|
reject(error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SbFetch
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { ISbStoriesParams, ISbResult, AsyncFn, HtmlEscapes } from './interfaces'
|
||||||
|
interface ISbParams extends ISbStoriesParams {
|
||||||
|
[key: string]: any
|
||||||
|
}
|
||||||
|
|
||||||
|
type ArrayFn = (...args: any) => void
|
||||||
|
|
||||||
|
type FlatMapFn = (...args: any) => [] | any
|
||||||
|
|
||||||
|
type RangeFn = (...args: any) => []
|
||||||
|
|
||||||
|
export class SbHelpers {
|
||||||
|
public isCDNUrl = (url = '') => url.indexOf('/cdn/') > -1
|
||||||
|
|
||||||
|
public getOptionsPage = (
|
||||||
|
options: ISbStoriesParams,
|
||||||
|
perPage = 25,
|
||||||
|
page = 1
|
||||||
|
) => {
|
||||||
|
return {
|
||||||
|
...options,
|
||||||
|
per_page: perPage,
|
||||||
|
page,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public delay = (ms: number) => new Promise((res) => setTimeout(res, ms))
|
||||||
|
|
||||||
|
public arrayFrom = (length = 0, func: ArrayFn) => [...Array(length)].map(func)
|
||||||
|
|
||||||
|
public range = (start = 0, end = start): Array<any> => {
|
||||||
|
const length = Math.abs(end - start) || 0
|
||||||
|
const step = start < end ? 1 : -1
|
||||||
|
return this.arrayFrom(length, (_, i: number) => i * step + start)
|
||||||
|
}
|
||||||
|
|
||||||
|
public asyncMap = async (arr: RangeFn[], func: AsyncFn) =>
|
||||||
|
Promise.all(arr.map(func))
|
||||||
|
|
||||||
|
public flatMap = (arr: ISbResult[] = [], func: FlatMapFn) =>
|
||||||
|
arr.map(func).reduce((xs, ys) => [...xs, ...ys], [])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @method stringify
|
||||||
|
* @param {Object} params
|
||||||
|
* @param {String} prefix
|
||||||
|
* @param {Boolean} isArray
|
||||||
|
* @return {String} Stringified object
|
||||||
|
*/
|
||||||
|
public stringify(
|
||||||
|
params: ISbParams,
|
||||||
|
prefix?: string,
|
||||||
|
isArray?: boolean
|
||||||
|
): string {
|
||||||
|
const pairs = []
|
||||||
|
for (const key in params) {
|
||||||
|
if (!Object.prototype.hasOwnProperty.call(params, key)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const value = params[key]
|
||||||
|
const enkey = isArray ? '' : encodeURIComponent(key)
|
||||||
|
let pair
|
||||||
|
if (typeof value === 'object') {
|
||||||
|
pair = this.stringify(
|
||||||
|
value,
|
||||||
|
prefix ? prefix + encodeURIComponent('[' + enkey + ']') : enkey,
|
||||||
|
Array.isArray(value)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
pair =
|
||||||
|
(prefix ? prefix + encodeURIComponent('[' + enkey + ']') : enkey) +
|
||||||
|
'=' +
|
||||||
|
encodeURIComponent(value)
|
||||||
|
}
|
||||||
|
pairs.push(pair)
|
||||||
|
}
|
||||||
|
return pairs.join('&')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @method escapeHTML
|
||||||
|
* @param {String} string text to be parsed
|
||||||
|
* @return {String} Text parsed
|
||||||
|
*/
|
||||||
|
public escapeHTML = function (string: string) {
|
||||||
|
const htmlEscapes = {
|
||||||
|
'&': '&',
|
||||||
|
'<': '<',
|
||||||
|
'>': '>',
|
||||||
|
'"': '"',
|
||||||
|
"'": ''',
|
||||||
|
} as HtmlEscapes
|
||||||
|
|
||||||
|
const reUnescapedHtml = /[&<>"']/g
|
||||||
|
const reHasUnescapedHtml = RegExp(reUnescapedHtml.source)
|
||||||
|
|
||||||
|
return string && reHasUnescapedHtml.test(string)
|
||||||
|
? string.replace(reUnescapedHtml, (chr) => htmlEscapes[chr])
|
||||||
|
: string
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
import { ISbNode, NodeSchema, MarkSchema, ISbComponentType } from './interfaces'
|
||||||
|
import { SbHelpers } from './sbHelpers'
|
||||||
|
|
||||||
|
const pick = function (attrs: Attrs, allowed: string[]) {
|
||||||
|
const h = {} as Attrs
|
||||||
|
|
||||||
|
for (const key in attrs) {
|
||||||
|
const value = attrs[key]
|
||||||
|
if (allowed.indexOf(key) > -1 && value !== null) {
|
||||||
|
h[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
const isEmailLinkType = (type: string) => type === 'email'
|
||||||
|
|
||||||
|
type Attrs = {
|
||||||
|
[key: string]: string | number | Array<ISbComponentType<any>>
|
||||||
|
}
|
||||||
|
|
||||||
|
// nodes
|
||||||
|
const horizontal_rule: NodeSchema = () => {
|
||||||
|
return {
|
||||||
|
singleTag: 'hr',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const blockquote: NodeSchema = () => {
|
||||||
|
return {
|
||||||
|
tag: 'blockquote',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const bullet_list: NodeSchema = () => {
|
||||||
|
return {
|
||||||
|
tag: 'ul',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const code_block: NodeSchema = (node: ISbNode) => {
|
||||||
|
return {
|
||||||
|
tag: [
|
||||||
|
'pre',
|
||||||
|
{
|
||||||
|
tag: 'code',
|
||||||
|
attrs: node.attrs,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const hard_break: NodeSchema = () => {
|
||||||
|
return {
|
||||||
|
singleTag: 'br',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const heading: NodeSchema = (node: ISbNode) => {
|
||||||
|
return {
|
||||||
|
tag: `h${node.attrs.level}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const image: NodeSchema = (node: ISbNode) => {
|
||||||
|
return {
|
||||||
|
singleTag: [
|
||||||
|
{
|
||||||
|
tag: 'img',
|
||||||
|
attrs: pick(node.attrs, ['src', 'alt', 'title']),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const list_item: NodeSchema = () => {
|
||||||
|
return {
|
||||||
|
tag: 'li',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const ordered_list: NodeSchema = () => {
|
||||||
|
return {
|
||||||
|
tag: 'ol',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const paragraph: NodeSchema = () => {
|
||||||
|
return {
|
||||||
|
tag: 'p',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const emoji: NodeSchema = (node: ISbNode) => {
|
||||||
|
const attrs = {
|
||||||
|
['data-type']: 'emoji',
|
||||||
|
['data-name']: node.attrs.name,
|
||||||
|
emoji: node.attrs.emoji
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
tag: [
|
||||||
|
{
|
||||||
|
tag: 'span',
|
||||||
|
attrs: attrs,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// marks
|
||||||
|
const bold: MarkSchema = () => {
|
||||||
|
return {
|
||||||
|
tag: 'b',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const strike: MarkSchema = () => {
|
||||||
|
return {
|
||||||
|
tag: 's',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const underline: MarkSchema = () => {
|
||||||
|
return {
|
||||||
|
tag: 'u',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const strong: MarkSchema = () => {
|
||||||
|
return {
|
||||||
|
tag: 'strong',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const code: MarkSchema = () => {
|
||||||
|
return {
|
||||||
|
tag: 'code',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const italic: MarkSchema = () => {
|
||||||
|
return {
|
||||||
|
tag: 'i',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const link: MarkSchema = (node: ISbNode) => {
|
||||||
|
if (!node.attrs) {
|
||||||
|
return {
|
||||||
|
tag: '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const escapeHTML = new SbHelpers().escapeHTML
|
||||||
|
const attrs = { ...node.attrs }
|
||||||
|
const { linktype = 'url' } = node.attrs
|
||||||
|
delete attrs.linktype
|
||||||
|
|
||||||
|
if (attrs.href) {
|
||||||
|
attrs.href = escapeHTML(node.attrs.href || '')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isEmailLinkType(linktype)) {
|
||||||
|
attrs.href = `mailto:${attrs.href}`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attrs.anchor) {
|
||||||
|
attrs.href = `${attrs.href}#${attrs.anchor}`
|
||||||
|
delete attrs.anchor
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attrs.custom) {
|
||||||
|
for (const key in attrs.custom) {
|
||||||
|
attrs[key] = attrs.custom[key]
|
||||||
|
}
|
||||||
|
delete attrs.custom
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
tag: [
|
||||||
|
{
|
||||||
|
tag: 'a',
|
||||||
|
attrs: attrs,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const styled: MarkSchema = (node: ISbNode) => {
|
||||||
|
return {
|
||||||
|
tag: [
|
||||||
|
{
|
||||||
|
tag: 'span',
|
||||||
|
attrs: node.attrs,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const subscript: MarkSchema = () => {
|
||||||
|
return {
|
||||||
|
tag: 'sub',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const superscript: MarkSchema = () => {
|
||||||
|
return {
|
||||||
|
tag: 'sup'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const anchor: MarkSchema = (node: ISbNode) => {
|
||||||
|
return {
|
||||||
|
tag: [
|
||||||
|
{
|
||||||
|
tag: 'span',
|
||||||
|
attrs: node.attrs,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const highlight: MarkSchema = (node: ISbNode) => {
|
||||||
|
if (!node.attrs?.color) return {
|
||||||
|
tag: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
const attrs = {
|
||||||
|
['style']: `background-color:${node.attrs.color};`,
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
tag: [
|
||||||
|
{
|
||||||
|
tag: 'span',
|
||||||
|
attrs,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const textStyle: MarkSchema = (node: ISbNode) => {
|
||||||
|
if (!node.attrs?.color) return {
|
||||||
|
tag: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
const attrs = {
|
||||||
|
['style']: `color:${node.attrs.color}`,
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
tag: [
|
||||||
|
{
|
||||||
|
tag: 'span',
|
||||||
|
attrs,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
nodes: {
|
||||||
|
horizontal_rule,
|
||||||
|
blockquote,
|
||||||
|
bullet_list,
|
||||||
|
code_block,
|
||||||
|
hard_break,
|
||||||
|
heading,
|
||||||
|
image,
|
||||||
|
list_item,
|
||||||
|
ordered_list,
|
||||||
|
paragraph,
|
||||||
|
emoji
|
||||||
|
},
|
||||||
|
marks: {
|
||||||
|
bold,
|
||||||
|
strike,
|
||||||
|
underline,
|
||||||
|
strong,
|
||||||
|
code,
|
||||||
|
italic,
|
||||||
|
link,
|
||||||
|
styled,
|
||||||
|
subscript,
|
||||||
|
superscript,
|
||||||
|
anchor,
|
||||||
|
highlight,
|
||||||
|
textStyle,
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import font from '../resources/fonts/IconFamily.woff'
|
|||||||
|
|
||||||
import ThemeCreator from './pages/ThemeCreator';
|
import ThemeCreator from './pages/ThemeCreator';
|
||||||
import Store from './pages/Store';
|
import Store from './pages/Store';
|
||||||
|
import Editor from './pages/Editor';
|
||||||
|
|
||||||
browser.storage.local.get().then(({ DarkMode }) => {
|
browser.storage.local.get().then(({ DarkMode }) => {
|
||||||
if (DarkMode) document.documentElement.classList.add('dark');
|
if (DarkMode) document.documentElement.classList.add('dark');
|
||||||
@@ -51,6 +52,7 @@ root.render(
|
|||||||
<Route path="/settings/embedded" element={<SettingsPage standalone={false} />} />
|
<Route path="/settings/embedded" element={<SettingsPage standalone={false} />} />
|
||||||
<Route path="/store" element={<Store />} />
|
<Route path="/store" element={<Store />} />
|
||||||
<Route path="/themeCreator" element={<ThemeCreator />} />
|
<Route path="/themeCreator" element={<ThemeCreator />} />
|
||||||
|
<Route path="/editor" element={<Editor />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</HashRouter>
|
</HashRouter>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
body {
|
||||||
|
background: transparent !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bn-container[data-color-scheme=dark] {
|
||||||
|
--bn-colors-editor-background: #18181B00 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bn-container > [contenteditable="true"] {
|
||||||
|
height: 100vh !important;
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import "@blocknote/core/fonts/inter.css";
|
||||||
|
import { useCreateBlockNote } from "@blocknote/react";
|
||||||
|
import { BlockNoteView } from "@blocknote/mantine";
|
||||||
|
import "@blocknote/mantine/style.css";
|
||||||
|
//import { generateHTML } from '@tiptap/html'
|
||||||
|
import './Editor.css'
|
||||||
|
|
||||||
|
|
||||||
|
export default function Editor() {
|
||||||
|
const editor = useCreateBlockNote({});
|
||||||
|
|
||||||
|
/* debounce on change to export to html */
|
||||||
|
editor._tiptapEditor.on('update', () => {
|
||||||
|
window.parent.postMessage({
|
||||||
|
type: 'message-html',
|
||||||
|
data: editor._tiptapEditor.getHTML()
|
||||||
|
}, '*')
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-screen">
|
||||||
|
<BlockNoteView editor={editor} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -231,10 +231,10 @@ function GetLightDarkModeString(darkMode: boolean) {
|
|||||||
async function addDarkLightToggle() {
|
async function addDarkLightToggle() {
|
||||||
const tooltipString = GetLightDarkModeString(settingsState.DarkMode);
|
const tooltipString = GetLightDarkModeString(settingsState.DarkMode);
|
||||||
const svgContent = settingsState.DarkMode ?
|
const svgContent = settingsState.DarkMode ?
|
||||||
'<defs><clipPath id="__lottie_element_80"><rect width="24" height="24" x="0" y="0"></rect></clipPath></defs><g clip-path="url(#__lottie_element_80)"><g style="display: block;" transform="matrix(1,0,0,1,12,12)" opacity="1"><g opacity="1" transform="matrix(1,0,0,1,0,0)"><path fill-opacity="1" d=" M0,-4 C-2.2100000381469727,-4 -4,-2.2100000381469727 -4,0 C-4,2.2100000381469727 -2.2100000381469727,4 0,4 C2.2100000381469727,4 4,2.2100000381469727 4,0 C4,-2.2100000381469727 2.2100000381469727,-4 0,-4z"></path></g></g><g style="display: block;" transform="matrix(1,0,0,1,12,12)" opacity="1"><g opacity="1" transform="matrix(1,0,0,1,0,0)"><path fill-opacity="1" d=" M0,6 C-3.309999942779541,6 -6,3.309999942779541 -6,0 C-6,-3.309999942779541 -3.309999942779541,-6 0,-6 C3.309999942779541,-6 6,-3.309999942779541 6,0 C6,3.309999942779541 3.309999942779541,6 0,6z M8,-3.309999942779541 C8,-3.309999942779541 8,-8 8,-8 C8,-8 3.309999942779541,-8 3.309999942779541,-8 C3.309999942779541,-8 0,-11.3100004196167 0,-11.3100004196167 C0,-11.3100004196167 -3.309999942779541,-8 -3.309999942779541,-8 C-3.309999942779541,-8 -8,-8 -8,-8 C-8,-8 -8,-3.309999942779541 -8,-3.309999942779541 C-8,-3.309999942779541 -11.3100004196167,0 -11.3100004196167,0 C-11.3100004196167,0 -8,3.309999942779541 -8,3.309999942779541 C-8,3.309999942779541 -8,8 -8,8 C-8,8 -3.309999942779541,8 -3.309999942779541,8 C-3.309999942779541,8 0,11.3100004196167 0,11.3100004196167 C0,11.3100004196167 3.309999942779541,8 3.309999942779541,8 C3.309999942779541,8 8,8 8,8 C8,8 8,3.309999942779541 8,3.309999942779541 C8,3.309999942779541 11.3100004196167,0 11.3100004196167,0 C11.3100004196167,0 8,-3.309999942779541 8,-3.309999942779541z"></path></g></g></g>' :
|
/* html */`<defs><clipPath id="__lottie_element_80"><rect width="24" height="24" x="0" y="0"></rect></clipPath></defs><g clip-path="url(#__lottie_element_80)"><g style="display: block;" transform="matrix(1,0,0,1,12,12)" opacity="1"><g opacity="1" transform="matrix(1,0,0,1,0,0)"><path fill-opacity="1" d=" M0,-4 C-2.2100000381469727,-4 -4,-2.2100000381469727 -4,0 C-4,2.2100000381469727 -2.2100000381469727,4 0,4 C2.2100000381469727,4 4,2.2100000381469727 4,0 C4,-2.2100000381469727 2.2100000381469727,-4 0,-4z"></path></g></g><g style="display: block;" transform="matrix(1,0,0,1,12,12)" opacity="1"><g opacity="1" transform="matrix(1,0,0,1,0,0)"><path fill-opacity="1" d=" M0,6 C-3.309999942779541,6 -6,3.309999942779541 -6,0 C-6,-3.309999942779541 -3.309999942779541,-6 0,-6 C3.309999942779541,-6 6,-3.309999942779541 6,0 C6,3.309999942779541 3.309999942779541,6 0,6z M8,-3.309999942779541 C8,-3.309999942779541 8,-8 8,-8 C8,-8 3.309999942779541,-8 3.309999942779541,-8 C3.309999942779541,-8 0,-11.3100004196167 0,-11.3100004196167 C0,-11.3100004196167 -3.309999942779541,-8 -3.309999942779541,-8 C-3.309999942779541,-8 -8,-8 -8,-8 C-8,-8 -8,-3.309999942779541 -8,-3.309999942779541 C-8,-3.309999942779541 -11.3100004196167,0 -11.3100004196167,0 C-11.3100004196167,0 -8,3.309999942779541 -8,3.309999942779541 C-8,3.309999942779541 -8,8 -8,8 C-8,8 -3.309999942779541,8 -3.309999942779541,8 C-3.309999942779541,8 0,11.3100004196167 0,11.3100004196167 C0,11.3100004196167 3.309999942779541,8 3.309999942779541,8 C3.309999942779541,8 8,8 8,8 C8,8 8,3.309999942779541 8,3.309999942779541 C8,3.309999942779541 11.3100004196167,0 11.3100004196167,0 C11.3100004196167,0 8,-3.309999942779541 8,-3.309999942779541z"></path></g></g></g>` :
|
||||||
'<defs><clipPath id="__lottie_element_263"><rect width="24" height="24" x="0" y="0"></rect></clipPath></defs><g clip-path="url(#__lottie_element_263)"><g style="display: block;" transform="matrix(1.5,0,0,1.5,7,12)" opacity="1"><g opacity="1" transform="matrix(1,0,0,1,0,0)"><path fill-opacity="1" d=" M0,-4 C-2.2100000381469727,-4 -1.2920000553131104,-2.2100000381469727 -1.2920000553131104,0 C-1.2920000553131104,2.2100000381469727 -2.2100000381469727,4 0,4 C2.2100000381469727,4 4,2.2100000381469727 4,0 C4,-2.2100000381469727 2.2100000381469727,-4 0,-4z"></path></g></g><g style="display: block;" transform="matrix(-1,0,0,-1,12,12)" opacity="1"><g opacity="1" transform="matrix(1,0,0,1,0,0)"><path fill-opacity="1" d=" M0,6 C-3.309999942779541,6 -6,3.309999942779541 -6,0 C-6,-3.309999942779541 -3.309999942779541,-6 0,-6 C3.309999942779541,-6 6,-3.309999942779541 6,0 C6,3.309999942779541 3.309999942779541,6 0,6z M8,-3.309999942779541 C8,-3.309999942779541 8,-8 8,-8 C8,-8 3.309999942779541,-8 3.309999942779541,-8 C3.309999942779541,-8 0,-11.3100004196167 0,-11.3100004196167 C0,-11.3100004196167 -3.309999942779541,-8 -3.309999942779541,-8 C-3.309999942779541,-8 -8,-8 -8,-8 C-8,-8 -8,-3.309999942779541 -8,-3.309999942779541 C-8,-3.309999942779541 -11.3100004196167,0 -11.3100004196167,0 C-11.3100004196167,0 -8,3.309999942779541 -8,3.309999942779541 C-8,3.309999942779541 -8,8 -8,8 C-8,8 -3.309999942779541,8 -3.309999942779541,8 C-3.309999942779541,8 0,11.3100004196167 0,11.3100004196167 C0,11.3100004196167 3.309999942779541,8 3.309999942779541,8 C3.309999942779541,8 8,8 8,8 C8,8 8,3.309999942779541 8,3.309999942779541 C8,3.309999942779541 11.3100004196167,0 11.3100004196167,0 C11.3100004196167,0 8,-3.309999942779541 8,-3.309999942779541z"></path></g></g></g>';
|
/* html */`<defs><clipPath id="__lottie_element_263"><rect width="24" height="24" x="0" y="0"></rect></clipPath></defs><g clip-path="url(#__lottie_element_263)"><g style="display: block;" transform="matrix(1.5,0,0,1.5,7,12)" opacity="1"><g opacity="1" transform="matrix(1,0,0,1,0,0)"><path fill-opacity="1" d=" M0,-4 C-2.2100000381469727,-4 -1.2920000553131104,-2.2100000381469727 -1.2920000553131104,0 C-1.2920000553131104,2.2100000381469727 -2.2100000381469727,4 0,4 C2.2100000381469727,4 4,2.2100000381469727 4,0 C4,-2.2100000381469727 2.2100000381469727,-4 0,-4z"></path></g></g><g style="display: block;" transform="matrix(-1,0,0,-1,12,12)" opacity="1"><g opacity="1" transform="matrix(1,0,0,1,0,0)"><path fill-opacity="1" d=" M0,6 C-3.309999942779541,6 -6,3.309999942779541 -6,0 C-6,-3.309999942779541 -3.309999942779541,-6 0,-6 C3.309999942779541,-6 6,-3.309999942779541 6,0 C6,3.309999942779541 3.309999942779541,6 0,6z M8,-3.309999942779541 C8,-3.309999942779541 8,-8 8,-8 C8,-8 3.309999942779541,-8 3.309999942779541,-8 C3.309999942779541,-8 0,-11.3100004196167 0,-11.3100004196167 C0,-11.3100004196167 -3.309999942779541,-8 -3.309999942779541,-8 C-3.309999942779541,-8 -8,-8 -8,-8 C-8,-8 -8,-3.309999942779541 -8,-3.309999942779541 C-8,-3.309999942779541 -11.3100004196167,0 -11.3100004196167,0 C-11.3100004196167,0 -8,3.309999942779541 -8,3.309999942779541 C-8,3.309999942779541 -8,8 -8,8 C-8,8 -3.309999942779541,8 -3.309999942779541,8 C-3.309999942779541,8 0,11.3100004196167 0,11.3100004196167 C0,11.3100004196167 3.309999942779541,8 3.309999942779541,8 C3.309999942779541,8 8,8 8,8 C8,8 8,3.309999942779541 8,3.309999942779541 C8,3.309999942779541 11.3100004196167,0 11.3100004196167,0 C11.3100004196167,0 8,-3.309999942779541 8,-3.309999942779541z"></path></g></g></g>`;
|
||||||
|
|
||||||
const LightDarkModeButton = stringToHTML(`
|
const LightDarkModeButton = stringToHTML(/* html */`
|
||||||
<button class="addedButton DarkLightButton tooltip" id="LightDarkModeButton">
|
<button class="addedButton DarkLightButton tooltip" id="LightDarkModeButton">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg">${svgContent}</svg>
|
<svg xmlns="http://www.w3.org/2000/svg">${svgContent}</svg>
|
||||||
<div class="tooltiptext topmenutooltip" id="darklighttooliptext">${tooltipString}</div>
|
<div class="tooltiptext topmenutooltip" id="darklighttooliptext">${tooltipString}</div>
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import stringToHTML from "../utils/stringToHTML";
|
||||||
|
import browser from 'webextension-polyfill';
|
||||||
|
import styles from "@blocknote/mantine/style.css?raw";
|
||||||
|
|
||||||
|
export default async function handleComposeMessage(): Promise<void> {
|
||||||
|
console.log('COMPOSE MESSAGE!');
|
||||||
|
|
||||||
|
const container: HTMLElement | null = document.querySelector('.pane .footer .pillbox');
|
||||||
|
const firstButton: HTMLButtonElement | null = document.querySelector('.pane .footer .pillbox button.first') as HTMLButtonElement;
|
||||||
|
|
||||||
|
if (container && firstButton) {
|
||||||
|
const buttonHTML = /* html */ `
|
||||||
|
<button class="button">
|
||||||
|
Better Editor
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
|
const button: HTMLElement = stringToHTML(buttonHTML);
|
||||||
|
|
||||||
|
// Append the new button as the second child of options
|
||||||
|
firstButton.parentNode?.insertBefore(button.firstChild as Node, firstButton.nextSibling);
|
||||||
|
|
||||||
|
// Add click event listeners to both buttons
|
||||||
|
container.addEventListener('click', (event: Event) => handleButtonClick(event, container, firstButton));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleButtonClick(event: Event, container: HTMLElement, firstButton: HTMLButtonElement): void {
|
||||||
|
const target = event.target as HTMLElement;
|
||||||
|
|
||||||
|
if (target && target.classList.contains('button')) {
|
||||||
|
const isBetterEditorButton = target.textContent?.trim() === 'Better Editor';
|
||||||
|
|
||||||
|
if (isBetterEditorButton) {
|
||||||
|
activateBetterEditor(container, firstButton);
|
||||||
|
} else {
|
||||||
|
deactivateBetterEditor(container, firstButton);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function activateBetterEditor(container: HTMLElement, firstButton: HTMLButtonElement): void {
|
||||||
|
firstButton.classList.remove('depressed');
|
||||||
|
container.children[1]?.classList.add('depressed');
|
||||||
|
|
||||||
|
const ckeInner = document.querySelector('.pane .cke_inner') as HTMLElement;
|
||||||
|
if (ckeInner) ckeInner.style.display = 'none';
|
||||||
|
|
||||||
|
let extensionEditor: HTMLIFrameElement | null = document.querySelector('.extension-editor') as HTMLIFrameElement;
|
||||||
|
if (extensionEditor) {
|
||||||
|
extensionEditor.style.display = 'block';
|
||||||
|
} else {
|
||||||
|
const extensionEditorIframe: HTMLIFrameElement = document.createElement('iframe');
|
||||||
|
extensionEditorIframe.src = `${browser.runtime.getURL('src/interface/index.html')}#editor`;
|
||||||
|
extensionEditorIframe.setAttribute('allowTransparency', 'true');
|
||||||
|
extensionEditorIframe.setAttribute('excludeDarkCheck', 'true');
|
||||||
|
extensionEditorIframe.classList.add('extension-editor');
|
||||||
|
document.getElementById('cke_editor1')?.appendChild(extensionEditorIframe);
|
||||||
|
}
|
||||||
|
|
||||||
|
extensionEditor = document.querySelector('.extension-editor') as HTMLIFrameElement;
|
||||||
|
const ckeEditor = document.querySelector('#cke_1_contents iframe.cke_wysiwyg_frame') as HTMLIFrameElement;
|
||||||
|
|
||||||
|
window.addEventListener('message', (event) => handleEditorMessage(event, ckeEditor), { once: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function deactivateBetterEditor(container: HTMLElement, firstButton: HTMLButtonElement): void {
|
||||||
|
const ckeInner = document.querySelector('.pane .cke_inner') as HTMLElement;
|
||||||
|
const extensionEditor = document.querySelector('.extension-editor') as HTMLIFrameElement;
|
||||||
|
|
||||||
|
if (ckeInner && extensionEditor) {
|
||||||
|
ckeInner.style.display = 'block';
|
||||||
|
firstButton.classList.add('depressed');
|
||||||
|
container.children[1]?.classList.remove('depressed');
|
||||||
|
extensionEditor.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleEditorMessage(event: MessageEvent, ckeEditor: HTMLIFrameElement): void {
|
||||||
|
if (!event.origin.includes(browser.runtime.id) || event.data.type !== "message-html") return;
|
||||||
|
|
||||||
|
console.log('Message from extension editor', event.data.data);
|
||||||
|
|
||||||
|
if (ckeEditor.contentDocument) {
|
||||||
|
ckeEditor.contentDocument.body.innerHTML = event.data.data + `<style>${styles}</style>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user