abc-sort-fix
This commit is contained in:
parent
433a74a58a
commit
6e0a830168
|
@ -158,11 +158,11 @@ export const CommentsTree = (props: Props) => {
|
||||||
<SimplifiedEditor
|
<SimplifiedEditor
|
||||||
quoteEnabled={true}
|
quoteEnabled={true}
|
||||||
imageEnabled={true}
|
imageEnabled={true}
|
||||||
options={{ autofocus: false }}
|
autoFocus={false}
|
||||||
submitByCtrlEnter={true}
|
submitByCtrlEnter={true}
|
||||||
placeholder={t('Write a comment...')}
|
placeholder={t('Write a comment...')}
|
||||||
onSubmit={(value) => handleSubmitComment(value)}
|
onSubmit={(value) => handleSubmitComment(value)}
|
||||||
reset={clearEditor()}
|
setClear={clearEditor()}
|
||||||
isPosting={posting()}
|
isPosting={posting()}
|
||||||
/>
|
/>
|
||||||
</ShowIfAuthenticated>
|
</ShowIfAuthenticated>
|
||||||
|
|
162
src/components/Editor/Editor.stories.tsx
Normal file
162
src/components/Editor/Editor.stories.tsx
Normal file
|
@ -0,0 +1,162 @@
|
||||||
|
import { Editor } from '@tiptap/core'
|
||||||
|
import { createSignal } from 'solid-js'
|
||||||
|
import { createStore } from 'solid-js/store'
|
||||||
|
import { Meta, StoryObj } from 'storybook-solidjs'
|
||||||
|
import { EditorContext, EditorContextType, ShoutForm } from '~/context/editor'
|
||||||
|
import { LocalizeContext, LocalizeContextType } from '~/context/localize'
|
||||||
|
import { SessionContext, SessionContextType } from '~/context/session'
|
||||||
|
import { SnackbarContext, SnackbarContextType } from '~/context/ui'
|
||||||
|
import { EditorComponent } from './Editor'
|
||||||
|
|
||||||
|
// Mock any necessary data
|
||||||
|
const mockSession = {
|
||||||
|
session: () => ({
|
||||||
|
user: {
|
||||||
|
app_data: {
|
||||||
|
profile: {
|
||||||
|
name: 'Test User',
|
||||||
|
slug: 'test-user'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
access_token: 'mock-access-token'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockLocalize = {
|
||||||
|
t: (key: string) => key,
|
||||||
|
lang: () => 'en'
|
||||||
|
}
|
||||||
|
|
||||||
|
const [_form, setForm] = createStore<ShoutForm>({
|
||||||
|
body: '',
|
||||||
|
slug: '',
|
||||||
|
shoutId: 0,
|
||||||
|
title: '',
|
||||||
|
selectedTopics: []
|
||||||
|
})
|
||||||
|
const [_formErrors, setFormErrors] = createStore({} as Record<keyof ShoutForm, string>)
|
||||||
|
const [editor, setEditor] = createSignal<Editor | undefined>()
|
||||||
|
const mockEditorContext: EditorContextType = {
|
||||||
|
countWords: () => 0,
|
||||||
|
isEditorPanelVisible: (): boolean => {
|
||||||
|
throw new Error('Function not implemented.')
|
||||||
|
},
|
||||||
|
wordCounter: (): { characters: number; words: number } => {
|
||||||
|
throw new Error('Function not implemented.')
|
||||||
|
},
|
||||||
|
form: {
|
||||||
|
layout: undefined,
|
||||||
|
shoutId: 0,
|
||||||
|
slug: '',
|
||||||
|
title: '',
|
||||||
|
subtitle: undefined,
|
||||||
|
lead: undefined,
|
||||||
|
description: undefined,
|
||||||
|
selectedTopics: [],
|
||||||
|
mainTopic: undefined,
|
||||||
|
body: '',
|
||||||
|
coverImageUrl: undefined,
|
||||||
|
media: undefined
|
||||||
|
},
|
||||||
|
formErrors: {} as Record<keyof ShoutForm, string>,
|
||||||
|
setEditor,
|
||||||
|
editor,
|
||||||
|
saveShout: (_form: ShoutForm): Promise<void> => {
|
||||||
|
throw new Error('Function not implemented.')
|
||||||
|
},
|
||||||
|
saveDraft: (_form: ShoutForm): Promise<void> => {
|
||||||
|
throw new Error('Function not implemented.')
|
||||||
|
},
|
||||||
|
saveDraftToLocalStorage: (_form: ShoutForm): void => {
|
||||||
|
throw new Error('Function not implemented.')
|
||||||
|
},
|
||||||
|
getDraftFromLocalStorage: (_shoutId: number): ShoutForm => {
|
||||||
|
throw new Error('Function not implemented.')
|
||||||
|
},
|
||||||
|
publishShout: (_form: ShoutForm): Promise<void> => {
|
||||||
|
throw new Error('Function not implemented.')
|
||||||
|
},
|
||||||
|
publishShoutById: (_shoutId: number): Promise<void> => {
|
||||||
|
throw new Error('Function not implemented.')
|
||||||
|
},
|
||||||
|
deleteShout: (_shoutId: number): Promise<boolean> => {
|
||||||
|
throw new Error('Function not implemented.')
|
||||||
|
},
|
||||||
|
toggleEditorPanel: (): void => {
|
||||||
|
throw new Error('Function not implemented.')
|
||||||
|
},
|
||||||
|
setForm,
|
||||||
|
setFormErrors
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockSnackbarContext = {
|
||||||
|
showSnackbar: console.log
|
||||||
|
}
|
||||||
|
|
||||||
|
const meta: Meta<typeof EditorComponent> = {
|
||||||
|
title: 'Components/Editor',
|
||||||
|
component: EditorComponent,
|
||||||
|
argTypes: {
|
||||||
|
shoutId: {
|
||||||
|
control: 'number',
|
||||||
|
description: 'Unique identifier for the shout (document)',
|
||||||
|
defaultValue: 1
|
||||||
|
},
|
||||||
|
initialContent: {
|
||||||
|
control: 'text',
|
||||||
|
description: 'Initial content for the editor',
|
||||||
|
defaultValue: ''
|
||||||
|
},
|
||||||
|
onChange: {
|
||||||
|
action: 'contentChanged',
|
||||||
|
description: 'Callback when the content changes'
|
||||||
|
},
|
||||||
|
disableCollaboration: {
|
||||||
|
control: 'boolean',
|
||||||
|
description: 'Disable collaboration features for Storybook',
|
||||||
|
defaultValue: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default meta
|
||||||
|
|
||||||
|
type Story = StoryObj<typeof EditorComponent>
|
||||||
|
|
||||||
|
export const Default: Story = {
|
||||||
|
render: (args) => {
|
||||||
|
const [_content, setContent] = createSignal(args.initialContent || '')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SessionContext.Provider value={mockSession as SessionContextType}>
|
||||||
|
<LocalizeContext.Provider value={mockLocalize as LocalizeContextType}>
|
||||||
|
<SnackbarContext.Provider value={mockSnackbarContext as SnackbarContextType}>
|
||||||
|
<EditorContext.Provider value={mockEditorContext as EditorContextType}>
|
||||||
|
<EditorComponent
|
||||||
|
{...args}
|
||||||
|
onChange={(text: string) => {
|
||||||
|
args.onChange(text)
|
||||||
|
setContent(text)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</EditorContext.Provider>
|
||||||
|
</SnackbarContext.Provider>
|
||||||
|
</LocalizeContext.Provider>
|
||||||
|
</SessionContext.Provider>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
args: {
|
||||||
|
shoutId: 1,
|
||||||
|
initialContent: '',
|
||||||
|
disableCollaboration: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WithInitialContent: Story = {
|
||||||
|
...Default,
|
||||||
|
args: {
|
||||||
|
...Default.args,
|
||||||
|
initialContent: '<p>This is some initial content in the editor.</p>'
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,5 +1,5 @@
|
||||||
import { HocuspocusProvider } from '@hocuspocus/provider'
|
import { HocuspocusProvider } from '@hocuspocus/provider'
|
||||||
import { Editor, isTextSelection } from '@tiptap/core'
|
import { Editor, EditorOptions, isTextSelection } from '@tiptap/core'
|
||||||
import { Bold } from '@tiptap/extension-bold'
|
import { Bold } from '@tiptap/extension-bold'
|
||||||
import { BubbleMenu } from '@tiptap/extension-bubble-menu'
|
import { BubbleMenu } from '@tiptap/extension-bubble-menu'
|
||||||
import { BulletList } from '@tiptap/extension-bullet-list'
|
import { BulletList } from '@tiptap/extension-bullet-list'
|
||||||
|
@ -26,7 +26,7 @@ import { Strike } from '@tiptap/extension-strike'
|
||||||
import { Text } from '@tiptap/extension-text'
|
import { Text } from '@tiptap/extension-text'
|
||||||
import { Underline } from '@tiptap/extension-underline'
|
import { Underline } from '@tiptap/extension-underline'
|
||||||
import { Show, createEffect, createMemo, createSignal, on, onCleanup } from 'solid-js'
|
import { Show, createEffect, createMemo, createSignal, on, onCleanup } from 'solid-js'
|
||||||
import { createTiptapEditor, useEditorHTML } from 'solid-tiptap'
|
import { createTiptapEditor } from 'solid-tiptap'
|
||||||
import uniqolor from 'uniqolor'
|
import uniqolor from 'uniqolor'
|
||||||
import { Doc } from 'yjs'
|
import { Doc } from 'yjs'
|
||||||
import { useEditorContext } from '~/context/editor'
|
import { useEditorContext } from '~/context/editor'
|
||||||
|
@ -47,14 +47,15 @@ import { Iframe } from './extensions/Iframe'
|
||||||
import { Span } from './extensions/Span'
|
import { Span } from './extensions/Span'
|
||||||
import { ToggleTextWrap } from './extensions/ToggleTextWrap'
|
import { ToggleTextWrap } from './extensions/ToggleTextWrap'
|
||||||
import { TrailingNode } from './extensions/TrailingNode'
|
import { TrailingNode } from './extensions/TrailingNode'
|
||||||
|
import { renderUploadedImage } from './renderUploadedImage'
|
||||||
|
|
||||||
import './Prosemirror.scss'
|
import './Prosemirror.scss'
|
||||||
import { renderUploadedImage } from './renderUploadedImage'
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
shoutId: number
|
shoutId: number
|
||||||
initialContent?: string
|
initialContent?: string
|
||||||
onChange: (text: string) => void
|
onChange: (text: string) => void
|
||||||
|
disableCollaboration?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const allowedImageTypes = new Set([
|
const allowedImageTypes = new Set([
|
||||||
|
@ -78,28 +79,14 @@ export const EditorComponent = (props: Props) => {
|
||||||
const [isCommonMarkup, setIsCommonMarkup] = createSignal(false)
|
const [isCommonMarkup, setIsCommonMarkup] = createSignal(false)
|
||||||
const [shouldShowTextBubbleMenu, setShouldShowTextBubbleMenu] = createSignal(false)
|
const [shouldShowTextBubbleMenu, setShouldShowTextBubbleMenu] = createSignal(false)
|
||||||
const { showSnackbar } = useSnackbar()
|
const { showSnackbar } = useSnackbar()
|
||||||
|
const { setEditor, countWords } = useEditorContext()
|
||||||
const docName = `shout-${props.shoutId}`
|
const [extensions, setExtensions] = createSignal<EditorOptions['extensions']>([])
|
||||||
|
const [editorElRef, setEditorElRef] = createSignal<HTMLElement | undefined>()
|
||||||
if (!yDocs[docName]) {
|
const [textBubbleMenuRef, setTextBubbleMenuRef] = createSignal<HTMLDivElement | undefined>()
|
||||||
yDocs[docName] = new Doc()
|
const [incutBubbleMenuRef, setIncutBubbleMenuRef] = createSignal<HTMLElement | undefined>()
|
||||||
}
|
const [figureBubbleMenuRef, setFigureBubbleMenuRef] = createSignal<HTMLElement | undefined>()
|
||||||
|
const [blockquoteBubbleMenuRef, setBlockquoteBubbleMenuRef] = createSignal<HTMLElement | undefined>()
|
||||||
if (!providers[docName]) {
|
const [floatingMenuRef, setFloatingMenuRef] = createSignal<HTMLDivElement | undefined>()
|
||||||
providers[docName] = new HocuspocusProvider({
|
|
||||||
url: 'wss://hocuspocus.discours.io',
|
|
||||||
name: docName,
|
|
||||||
document: yDocs[docName],
|
|
||||||
token: session()?.access_token || ''
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const [editorElRef, setEditorElRef] = createSignal<HTMLElement>()
|
|
||||||
let textBubbleMenuRef: HTMLDivElement | undefined
|
|
||||||
let incutBubbleMenuRef: HTMLElement | undefined
|
|
||||||
let figureBubbleMenuRef: HTMLElement | undefined
|
|
||||||
let blockquoteBubbleMenuRef: HTMLElement | undefined
|
|
||||||
let floatingMenuRef: HTMLDivElement | undefined
|
|
||||||
|
|
||||||
const handleClipboardPaste = async () => {
|
const handleClipboardPaste = async () => {
|
||||||
try {
|
try {
|
||||||
|
@ -130,13 +117,8 @@ export const EditorComponent = (props: Props) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const { initialContent } = props
|
const editor = createTiptapEditor(() => ({
|
||||||
const { editor, setEditor, countWords } = useEditorContext()
|
element: editorElRef()!,
|
||||||
createEffect(
|
|
||||||
on(editorElRef, (ee: HTMLElement | undefined) => {
|
|
||||||
if (ee) {
|
|
||||||
const freshEditor = createTiptapEditor<HTMLElement>(() => ({
|
|
||||||
element: ee,
|
|
||||||
editorProps: {
|
editorProps: {
|
||||||
attributes: {
|
attributes: {
|
||||||
class: 'articleEditor'
|
class: 'articleEditor'
|
||||||
|
@ -149,57 +131,63 @@ export const EditorComponent = (props: Props) => {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
extensions: [
|
extensions: extensions(),
|
||||||
|
onTransaction: ({ transaction, editor }) => {
|
||||||
|
if (transaction.docChanged) {
|
||||||
|
// Get the current HTML content from the editor
|
||||||
|
const html = editor.getHTML()
|
||||||
|
|
||||||
|
// Trigger the onChange callback with the updated HTML
|
||||||
|
html && props.onChange(html)
|
||||||
|
|
||||||
|
// Get the word count from the editor's storage (using CharacterCount)
|
||||||
|
const wordCount = editor.storage.characterCount.words()
|
||||||
|
|
||||||
|
// Update the word count
|
||||||
|
wordCount && countWords(wordCount)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
content: props.initialContent || ''
|
||||||
|
}))
|
||||||
|
|
||||||
|
createEffect(() => editor() && setEditor(editor() as Editor))
|
||||||
|
|
||||||
|
createEffect(
|
||||||
|
on(
|
||||||
|
[extensions, editorElRef, author, () => `shout-${props.shoutId}`],
|
||||||
|
([eee, element, a, docName]) =>
|
||||||
|
eee.length === 0 &&
|
||||||
|
a &&
|
||||||
|
element &&
|
||||||
|
setExtensions([
|
||||||
Document,
|
Document,
|
||||||
Text,
|
Text,
|
||||||
Paragraph,
|
Paragraph,
|
||||||
Dropcursor,
|
|
||||||
CustomBlockquote,
|
|
||||||
Bold,
|
Bold,
|
||||||
Italic,
|
Italic,
|
||||||
Span,
|
|
||||||
ToggleTextWrap,
|
|
||||||
Strike,
|
Strike,
|
||||||
HorizontalRule.configure({
|
Heading.configure({ levels: [2, 3, 4] }),
|
||||||
HTMLAttributes: {
|
|
||||||
class: 'horizontalRule'
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
Underline,
|
|
||||||
Link.extend({
|
|
||||||
inclusive: false
|
|
||||||
}).configure({
|
|
||||||
autolink: true,
|
|
||||||
openOnClick: false
|
|
||||||
}),
|
|
||||||
Heading.configure({
|
|
||||||
levels: [2, 3, 4]
|
|
||||||
}),
|
|
||||||
BulletList,
|
BulletList,
|
||||||
OrderedList,
|
OrderedList,
|
||||||
ListItem,
|
ListItem,
|
||||||
Collaboration.configure({
|
|
||||||
document: yDocs[docName]
|
HorizontalRule.configure({ HTMLAttributes: { class: 'horizontalRule' } }),
|
||||||
}),
|
Dropcursor,
|
||||||
|
CustomBlockquote,
|
||||||
|
Span,
|
||||||
|
ToggleTextWrap,
|
||||||
|
Underline,
|
||||||
|
Link.extend({ inclusive: false }).configure({ autolink: true, openOnClick: false }),
|
||||||
|
Collaboration.configure({ document: yDocs[docName] }),
|
||||||
CollaborationCursor.configure({
|
CollaborationCursor.configure({
|
||||||
provider: providers[docName],
|
provider: providers[docName],
|
||||||
user: {
|
user: { name: a.name, color: uniqolor(a.slug).color }
|
||||||
name: author().name,
|
|
||||||
color: uniqolor(author().slug).color
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
Placeholder.configure({
|
|
||||||
placeholder: t('Add a link or click plus to embed media')
|
|
||||||
}),
|
}),
|
||||||
|
Placeholder.configure({ placeholder: t('Add a link or click plus to embed media') }),
|
||||||
Focus,
|
Focus,
|
||||||
Gapcursor,
|
Gapcursor,
|
||||||
HardBreak,
|
HardBreak,
|
||||||
Highlight.configure({
|
Highlight.configure({ multicolor: true, HTMLAttributes: { class: 'highlight' } }),
|
||||||
multicolor: true,
|
|
||||||
HTMLAttributes: {
|
|
||||||
class: 'highlight'
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
Image,
|
Image,
|
||||||
Iframe,
|
Iframe,
|
||||||
Figure,
|
Figure,
|
||||||
|
@ -209,96 +197,107 @@ export const EditorComponent = (props: Props) => {
|
||||||
CharacterCount.configure(), // https://github.com/ueberdosis/tiptap/issues/2589#issuecomment-1093084689
|
CharacterCount.configure(), // https://github.com/ueberdosis/tiptap/issues/2589#issuecomment-1093084689
|
||||||
BubbleMenu.configure({
|
BubbleMenu.configure({
|
||||||
pluginKey: 'textBubbleMenu',
|
pluginKey: 'textBubbleMenu',
|
||||||
element: textBubbleMenuRef,
|
element: textBubbleMenuRef(),
|
||||||
shouldShow: ({ editor: e, view, state, from, to }) => {
|
shouldShow: ({ editor: e, view, state: { doc, selection }, from, to }) => {
|
||||||
const { doc, selection } = state
|
const isEmptyTextBlock = doc.textBetween(from, to).length === 0 && isTextSelection(selection)
|
||||||
const { empty } = selection
|
isEmptyTextBlock &&
|
||||||
const isEmptyTextBlock =
|
|
||||||
doc.textBetween(from, to).length === 0 && isTextSelection(selection)
|
|
||||||
if (isEmptyTextBlock) {
|
|
||||||
e?.chain().focus().removeTextWrap({ class: 'highlight-fake-selection' }).run()
|
e?.chain().focus().removeTextWrap({ class: 'highlight-fake-selection' }).run()
|
||||||
}
|
|
||||||
setIsCommonMarkup(e?.isActive('figcaption'))
|
setIsCommonMarkup(e?.isActive('figcaption'))
|
||||||
const result =
|
const result =
|
||||||
(view.hasFocus() &&
|
(view.hasFocus() &&
|
||||||
!empty &&
|
!selection.empty &&
|
||||||
!isEmptyTextBlock &&
|
!isEmptyTextBlock &&
|
||||||
!e.isActive('image') &&
|
!e.isActive('image') &&
|
||||||
!e.isActive('figure')) ||
|
!e.isActive('figure')) ||
|
||||||
e.isActive('footnote') ||
|
e.isActive('footnote') ||
|
||||||
(e.isActive('figcaption') && !empty)
|
(e.isActive('figcaption') && !selection.empty)
|
||||||
setShouldShowTextBubbleMenu(result)
|
setShouldShowTextBubbleMenu(result)
|
||||||
return result
|
return result
|
||||||
},
|
},
|
||||||
tippyOptions: {
|
tippyOptions: {
|
||||||
onHide: () => {
|
onHide: () => editor()?.commands.focus() as false
|
||||||
const fe = freshEditor() as Editor
|
|
||||||
fe?.commands.focus()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
BubbleMenu.configure({
|
BubbleMenu.configure({
|
||||||
pluginKey: 'blockquoteBubbleMenu',
|
pluginKey: 'blockquoteBubbleMenu',
|
||||||
element: blockquoteBubbleMenuRef,
|
element: blockquoteBubbleMenuRef(),
|
||||||
shouldShow: ({ editor: e, view, state }) => {
|
shouldShow: ({ editor: e, view, state }) =>
|
||||||
const { empty } = state.selection
|
view.hasFocus() && !state.selection.empty && e.isActive('blockquote')
|
||||||
return view.hasFocus() && !empty && e.isActive('blockquote')
|
|
||||||
}
|
|
||||||
}),
|
}),
|
||||||
BubbleMenu.configure({
|
BubbleMenu.configure({
|
||||||
pluginKey: 'figureBubbleMenu',
|
pluginKey: 'figureBubbleMenu',
|
||||||
element: figureBubbleMenuRef,
|
element: figureBubbleMenuRef(),
|
||||||
shouldShow: ({ editor: e, view, state }) => {
|
shouldShow: ({ editor: e, view, state }) =>
|
||||||
const { empty } = state.selection
|
view.hasFocus() && !state.selection.empty && e.isActive('figure')
|
||||||
return view.hasFocus() && !empty && e.isActive('figure')
|
|
||||||
}
|
|
||||||
}),
|
}),
|
||||||
BubbleMenu.configure({
|
BubbleMenu.configure({
|
||||||
pluginKey: 'incutBubbleMenu',
|
pluginKey: 'incutBubbleMenu',
|
||||||
element: incutBubbleMenuRef,
|
element: incutBubbleMenuRef(),
|
||||||
shouldShow: ({ editor: e, view, state }) => {
|
shouldShow: ({ editor: e, view, state }) =>
|
||||||
const { empty } = state.selection
|
view.hasFocus() && !state.selection.empty && e.isActive('figcaption')
|
||||||
return view.hasFocus() && !empty && e.isActive('figcaption')
|
|
||||||
}
|
|
||||||
}),
|
}),
|
||||||
FloatingMenu.configure({
|
FloatingMenu.configure({
|
||||||
element: floatingMenuRef,
|
element: floatingMenuRef(),
|
||||||
pluginKey: 'floatingMenu',
|
pluginKey: 'floatingMenu',
|
||||||
shouldShow: ({ editor: e, state }) => {
|
shouldShow: ({ editor: e, state: { selection } }) => {
|
||||||
const { $anchor, empty } = state.selection
|
const isRootDepth = selection.$anchor.depth === 1
|
||||||
const isRootDepth = $anchor.depth === 1
|
if (!(isRootDepth && selection.empty)) return false
|
||||||
|
|
||||||
if (!(isRootDepth && empty)) return false
|
|
||||||
|
|
||||||
return !(e.isActive('codeBlock') || e.isActive('heading'))
|
return !(e.isActive('codeBlock') || e.isActive('heading'))
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
TrailingNode,
|
TrailingNode,
|
||||||
ArticleNode
|
ArticleNode
|
||||||
],
|
])
|
||||||
onTransaction: ({ transaction }) => {
|
)
|
||||||
if (transaction.docChanged) {
|
)
|
||||||
const fe = freshEditor()
|
|
||||||
if (fe) {
|
|
||||||
const changeHandle = useEditorHTML(() => fe as Editor | undefined)
|
|
||||||
props.onChange(changeHandle() || '')
|
|
||||||
countWords(fe?.storage.characterCount.words())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
content: initialContent
|
|
||||||
}))
|
|
||||||
|
|
||||||
if (freshEditor) {
|
createEffect(
|
||||||
editorElRef()?.addEventListener('focus', (_event) => {
|
on(
|
||||||
if (freshEditor()?.isActive('figcaption')) {
|
[
|
||||||
freshEditor()?.commands.focus()
|
() => !props.disableCollaboration,
|
||||||
|
() => `shout-${props.shoutId}`,
|
||||||
|
() => session()?.access_token || '',
|
||||||
|
author
|
||||||
|
],
|
||||||
|
([collab, docName, token, profile]) => {
|
||||||
|
if (!yDocs[docName]) {
|
||||||
|
yDocs[docName] = new Doc()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!providers[docName]) {
|
||||||
|
providers[docName] = new HocuspocusProvider({
|
||||||
|
url: 'wss://hocuspocus.discours.io',
|
||||||
|
name: docName,
|
||||||
|
document: yDocs[docName],
|
||||||
|
token
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
collab &&
|
||||||
|
setExtensions((old: EditorOptions['extensions']) => [
|
||||||
|
...old,
|
||||||
|
Collaboration.configure({ document: yDocs[docName] }),
|
||||||
|
CollaborationCursor.configure({
|
||||||
|
provider: providers[docName],
|
||||||
|
user: {
|
||||||
|
name: profile.name,
|
||||||
|
color: uniqolor(profile.slug).color
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
setEditor(freshEditor() as Editor)
|
])
|
||||||
}
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
createEffect(
|
||||||
|
on(editorElRef, (ee: HTMLElement | undefined) => {
|
||||||
|
ee?.addEventListener('focus', (_event) => {
|
||||||
|
if (editor()?.isActive('figcaption')) {
|
||||||
|
editor()?.commands.focus()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
|
@ -318,27 +317,12 @@ export const EditorComponent = (props: Props) => {
|
||||||
shouldShow={shouldShowTextBubbleMenu()}
|
shouldShow={shouldShowTextBubbleMenu()}
|
||||||
isCommonMarkup={isCommonMarkup()}
|
isCommonMarkup={isCommonMarkup()}
|
||||||
editor={editor() as Editor}
|
editor={editor() as Editor}
|
||||||
ref={(el) => (textBubbleMenuRef = el)}
|
ref={setTextBubbleMenuRef}
|
||||||
/>
|
/>
|
||||||
<BlockquoteBubbleMenu
|
<BlockquoteBubbleMenu ref={setBlockquoteBubbleMenuRef} editor={editor() as Editor} />
|
||||||
ref={(el) => {
|
<FigureBubbleMenu editor={editor() as Editor} ref={setFigureBubbleMenuRef} />
|
||||||
blockquoteBubbleMenuRef = el
|
<IncutBubbleMenu editor={editor() as Editor} ref={setIncutBubbleMenuRef} />
|
||||||
}}
|
<EditorFloatingMenu editor={editor() as Editor} ref={setFloatingMenuRef} />
|
||||||
editor={editor() as Editor}
|
|
||||||
/>
|
|
||||||
<FigureBubbleMenu
|
|
||||||
editor={editor() as Editor}
|
|
||||||
ref={(el) => {
|
|
||||||
figureBubbleMenuRef = el
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<IncutBubbleMenu
|
|
||||||
editor={editor() as Editor}
|
|
||||||
ref={(el) => {
|
|
||||||
incutBubbleMenuRef = el
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<EditorFloatingMenu editor={editor() as Editor} ref={(el) => (floatingMenuRef = el)} />
|
|
||||||
</Show>
|
</Show>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|
|
@ -25,12 +25,12 @@ export const Panel = (props: Props) => {
|
||||||
const {
|
const {
|
||||||
isEditorPanelVisible,
|
isEditorPanelVisible,
|
||||||
wordCounter,
|
wordCounter,
|
||||||
editor,
|
|
||||||
form,
|
form,
|
||||||
toggleEditorPanel,
|
toggleEditorPanel,
|
||||||
saveShout,
|
saveShout,
|
||||||
saveDraft,
|
saveDraft,
|
||||||
publishShout
|
publishShout,
|
||||||
|
editor
|
||||||
} = useEditorContext()
|
} = useEditorContext()
|
||||||
|
|
||||||
let containerRef: HTMLElement | undefined
|
let containerRef: HTMLElement | undefined
|
||||||
|
@ -58,7 +58,7 @@ export const Panel = (props: Props) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const html = useEditorHTML(() => editor()) // FIXME: lost current() call
|
const html = useEditorHTML(editor)
|
||||||
|
|
||||||
const handleFixTypographyClick = () => {
|
const handleFixTypographyClick = () => {
|
||||||
editor()?.commands.setContent(typograf.execute(html() || '')) // here too
|
editor()?.commands.setContent(typograf.execute(html() || '')) // here too
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { Editor } from '@tiptap/core'
|
||||||
import { Blockquote } from '@tiptap/extension-blockquote'
|
import { Blockquote } from '@tiptap/extension-blockquote'
|
||||||
import { BubbleMenu } from '@tiptap/extension-bubble-menu'
|
import { BubbleMenu } from '@tiptap/extension-bubble-menu'
|
||||||
import { CharacterCount } from '@tiptap/extension-character-count'
|
import { CharacterCount } from '@tiptap/extension-character-count'
|
||||||
|
@ -14,12 +15,14 @@ import {
|
||||||
useEditorIsEmpty,
|
useEditorIsEmpty,
|
||||||
useEditorIsFocused
|
useEditorIsFocused
|
||||||
} from 'solid-tiptap'
|
} from 'solid-tiptap'
|
||||||
|
|
||||||
import { useLocalize } from '~/context/localize'
|
import { useLocalize } from '~/context/localize'
|
||||||
|
import { useUI } from '~/context/ui'
|
||||||
|
import { base } from '~/lib/editorOptions'
|
||||||
import { UploadedFile } from '~/types/upload'
|
import { UploadedFile } from '~/types/upload'
|
||||||
import { Button } from '../_shared/Button'
|
import { Button } from '../_shared/Button'
|
||||||
import { Icon } from '../_shared/Icon'
|
import { Icon } from '../_shared/Icon'
|
||||||
import { Loading } from '../_shared/Loading'
|
import { Loading } from '../_shared/Loading'
|
||||||
|
import { Modal } from '../_shared/Modal/Modal'
|
||||||
import { Popover } from '../_shared/Popover'
|
import { Popover } from '../_shared/Popover'
|
||||||
import { ShowOnlyOnClient } from '../_shared/ShowOnlyOnClient'
|
import { ShowOnlyOnClient } from '../_shared/ShowOnlyOnClient'
|
||||||
import { LinkBubbleMenuModule } from './LinkBubbleMenu'
|
import { LinkBubbleMenuModule } from './LinkBubbleMenu'
|
||||||
|
@ -27,14 +30,10 @@ import { TextBubbleMenu } from './TextBubbleMenu'
|
||||||
import { UploadModalContent } from './UploadModalContent'
|
import { UploadModalContent } from './UploadModalContent'
|
||||||
import { Figcaption } from './extensions/Figcaption'
|
import { Figcaption } from './extensions/Figcaption'
|
||||||
import { Figure } from './extensions/Figure'
|
import { Figure } from './extensions/Figure'
|
||||||
|
|
||||||
import { Editor } from '@tiptap/core'
|
|
||||||
import { useUI } from '~/context/ui'
|
|
||||||
import { base } from '~/lib/editorOptions'
|
|
||||||
import { Modal } from '../_shared/Modal/Modal'
|
|
||||||
import styles from './SimplifiedEditor.module.scss'
|
|
||||||
import { renderUploadedImage } from './renderUploadedImage'
|
import { renderUploadedImage } from './renderUploadedImage'
|
||||||
|
|
||||||
|
import styles from './SimplifiedEditor.module.scss'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
placeholder: string
|
placeholder: string
|
||||||
initialContent?: string
|
initialContent?: string
|
||||||
|
|
|
@ -5,7 +5,7 @@ import { Icon } from '~/components/_shared/Icon'
|
||||||
import { useLocalize } from '~/context/localize'
|
import { useLocalize } from '~/context/localize'
|
||||||
import { useTopics } from '~/context/topics'
|
import { useTopics } from '~/context/topics'
|
||||||
import type { Topic } from '~/graphql/schema/core.gen'
|
import type { Topic } from '~/graphql/schema/core.gen'
|
||||||
import { ruChars } from '~/intl/chars'
|
import { notLatin } from '~/intl/chars'
|
||||||
import { getRandomItemsFromArray } from '~/utils/random'
|
import { getRandomItemsFromArray } from '~/utils/random'
|
||||||
import styles from './TopicsNav.module.scss'
|
import styles from './TopicsNav.module.scss'
|
||||||
|
|
||||||
|
@ -13,7 +13,7 @@ export const RandomTopics = () => {
|
||||||
const { sortedTopics } = useTopics()
|
const { sortedTopics } = useTopics()
|
||||||
const { lang, t } = useLocalize()
|
const { lang, t } = useLocalize()
|
||||||
const tag = (topic: Topic) =>
|
const tag = (topic: Topic) =>
|
||||||
ruChars.test(topic.title || '') && lang() !== 'ru' ? topic.slug : topic.title
|
notLatin.test(topic.title || '') && lang() !== 'ru' ? topic.slug : topic.title
|
||||||
const [randomTopics, setRandomTopics] = createSignal<Topic[]>([])
|
const [randomTopics, setRandomTopics] = createSignal<Topic[]>([])
|
||||||
createEffect(
|
createEffect(
|
||||||
on(sortedTopics, (ttt: Topic[]) => {
|
on(sortedTopics, (ttt: Topic[]) => {
|
||||||
|
|
|
@ -11,7 +11,6 @@ import { useLocalize } from '~/context/localize'
|
||||||
import type { Author } from '~/graphql/schema/core.gen'
|
import type { Author } from '~/graphql/schema/core.gen'
|
||||||
import { dummyFilter } from '~/intl/dummyFilter'
|
import { dummyFilter } from '~/intl/dummyFilter'
|
||||||
import { authorLetterReduce, translateAuthor } from '~/intl/translate'
|
import { authorLetterReduce, translateAuthor } from '~/intl/translate'
|
||||||
// import { byFirstChar, byStat } from '~/lib/sort'
|
|
||||||
import { scrollHandler } from '~/utils/scroll'
|
import { scrollHandler } from '~/utils/scroll'
|
||||||
import styles from './AllAuthors.module.scss'
|
import styles from './AllAuthors.module.scss'
|
||||||
import stylesAuthorList from './AuthorsList.module.scss'
|
import stylesAuthorList from './AuthorsList.module.scss'
|
||||||
|
@ -25,8 +24,8 @@ type Props = {
|
||||||
|
|
||||||
export const AUTHORS_PER_PAGE = 20
|
export const AUTHORS_PER_PAGE = 20
|
||||||
export const ABC = {
|
export const ABC = {
|
||||||
ru: 'АБВГДЕЁЖЗИЙКЛМНОПРСТУФХЦЧШЩЪЫЬЭЮЯ#',
|
ru: 'АБВГДЕЁЖЗИЙКЛМНОПРСТУФХЦЧШЩЪЫЬЭЮЯ@',
|
||||||
en: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ#'
|
en: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ@'
|
||||||
}
|
}
|
||||||
|
|
||||||
// useAuthors sorted from context, set filter/sort
|
// useAuthors sorted from context, set filter/sort
|
||||||
|
|
|
@ -6,8 +6,9 @@ import { SearchField } from '~/components/_shared/SearchField'
|
||||||
import { useLocalize } from '~/context/localize'
|
import { useLocalize } from '~/context/localize'
|
||||||
import { useTopics } from '~/context/topics'
|
import { useTopics } from '~/context/topics'
|
||||||
import type { Topic } from '~/graphql/schema/core.gen'
|
import type { Topic } from '~/graphql/schema/core.gen'
|
||||||
import { enChars, ruChars } from '~/intl/chars'
|
import { findFirstReadableCharIndex, notLatin, notRus } from '~/intl/chars'
|
||||||
import { dummyFilter } from '~/intl/dummyFilter'
|
import { dummyFilter } from '~/intl/dummyFilter'
|
||||||
|
import { capitalize } from '~/utils/capitalize'
|
||||||
import { scrollHandler } from '~/utils/scroll'
|
import { scrollHandler } from '~/utils/scroll'
|
||||||
import { TopicBadge } from '../../Topic/TopicBadge'
|
import { TopicBadge } from '../../Topic/TopicBadge'
|
||||||
import styles from './AllTopics.module.scss'
|
import styles from './AllTopics.module.scss'
|
||||||
|
@ -28,16 +29,20 @@ export const AllTopics = (props: Props) => {
|
||||||
const { setTopicsSort, sortedTopics } = useTopics()
|
const { setTopicsSort, sortedTopics } = useTopics()
|
||||||
const topics = createMemo(() => sortedTopics() || props.topics)
|
const topics = createMemo(() => sortedTopics() || props.topics)
|
||||||
const [searchParams, changeSearchParams] = useSearchParams<{ by?: string }>()
|
const [searchParams, changeSearchParams] = useSearchParams<{ by?: string }>()
|
||||||
|
onMount(() => changeSearchParams({ by: 'shouts' }))
|
||||||
createEffect(on(() => searchParams?.by || 'shouts', setTopicsSort, { defer: true }))
|
createEffect(on(() => searchParams?.by || 'shouts', setTopicsSort, { defer: true }))
|
||||||
onMount(() => !searchParams?.by && changeSearchParams({ by: 'shouts' }))
|
|
||||||
|
|
||||||
// sorted derivative
|
// sorted derivative
|
||||||
const byLetter = createMemo<{ [letter: string]: Topic[] }>(() => {
|
const byLetter = createMemo<{ [letter: string]: Topic[] }>(() => {
|
||||||
return topics().reduce(
|
return topics().reduce(
|
||||||
(acc, topic) => {
|
(acc, topic) => {
|
||||||
let letter = lang() === 'en' ? topic.slug[0].toUpperCase() : (topic?.title?.[0] || '').toUpperCase()
|
const firstCharIndex = findFirstReadableCharIndex(topic?.title || '')
|
||||||
if (enChars.test(letter) && lang() === 'ru') letter = '#'
|
let letter =
|
||||||
if (ruChars.test(letter) && lang() === 'en') letter = '#'
|
lang() === 'en'
|
||||||
|
? topic.slug[0].toUpperCase()
|
||||||
|
: (topic?.title?.[firstCharIndex] || '').toUpperCase()
|
||||||
|
if (notRus.test(letter) && lang() === 'ru') letter = '#'
|
||||||
|
if (notLatin.test(letter) && lang() === 'en') letter = '#'
|
||||||
if (!acc[letter]) acc[letter] = []
|
if (!acc[letter]) acc[letter] = []
|
||||||
acc[letter].push(topic)
|
acc[letter].push(topic)
|
||||||
return acc
|
return acc
|
||||||
|
@ -124,7 +129,9 @@ export const AllTopics = (props: Props) => {
|
||||||
<For each={byLetter()[letter]}>
|
<For each={byLetter()[letter]}>
|
||||||
{(topic) => (
|
{(topic) => (
|
||||||
<div class={clsx(styles.topicTitle, 'col-sm-12 col-md-8')}>
|
<div class={clsx(styles.topicTitle, 'col-sm-12 col-md-8')}>
|
||||||
<A href={`/topic/${topic.slug}`}>{topic.title || topic.slug}</A>
|
<A href={`/topic/${topic.slug}`}>
|
||||||
|
{lang() !== 'ru' ? capitalize(topic.slug.replaceAll('-', ' ')) : topic.title}
|
||||||
|
</A>
|
||||||
<Show when={topic.stat?.shouts || 0}>
|
<Show when={topic.stat?.shouts || 0}>
|
||||||
<span class={styles.articlesCounter}>{topic.stat?.shouts || 0}</span>
|
<span class={styles.articlesCounter}>{topic.stat?.shouts || 0}</span>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
|
@ -46,7 +46,6 @@ export const EditSettingsView = (props: Props) => {
|
||||||
const [isScrolled, setIsScrolled] = createSignal(false)
|
const [isScrolled, setIsScrolled] = createSignal(false)
|
||||||
const { session } = useSession()
|
const { session } = useSession()
|
||||||
const client = createMemo(() => graphqlClientCreate(coreApiUrl, session()?.access_token))
|
const client = createMemo(() => graphqlClientCreate(coreApiUrl, session()?.access_token))
|
||||||
|
|
||||||
const { form, setForm, saveDraft, saveDraftToLocalStorage, getDraftFromLocalStorage } = useEditorContext()
|
const { form, setForm, saveDraft, saveDraftToLocalStorage, getDraftFromLocalStorage } = useEditorContext()
|
||||||
const [shoutTopics, setShoutTopics] = createSignal<Topic[]>([])
|
const [shoutTopics, setShoutTopics] = createSignal<Topic[]>([])
|
||||||
const [draft, setDraft] = createSignal()
|
const [draft, setDraft] = createSignal()
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { Editor } from '@tiptap/core'
|
||||||
import { clsx } from 'clsx'
|
import { clsx } from 'clsx'
|
||||||
import deepEqual from 'fast-deep-equal'
|
import deepEqual from 'fast-deep-equal'
|
||||||
import {
|
import {
|
||||||
|
@ -32,7 +33,7 @@ import { isDesktop } from '~/lib/mediaQuery'
|
||||||
import { LayoutType } from '~/types/common'
|
import { LayoutType } from '~/types/common'
|
||||||
import { MediaItem } from '~/types/mediaitem'
|
import { MediaItem } from '~/types/mediaitem'
|
||||||
import { clone } from '~/utils/clone'
|
import { clone } from '~/utils/clone'
|
||||||
import { Editor, Panel } from '../../Editor'
|
import { Editor as EditorComponent, Panel } from '../../Editor'
|
||||||
import { AudioUploader } from '../../Editor/AudioUploader'
|
import { AudioUploader } from '../../Editor/AudioUploader'
|
||||||
import { AutoSaveNotice } from '../../Editor/AutoSaveNotice'
|
import { AutoSaveNotice } from '../../Editor/AutoSaveNotice'
|
||||||
import { VideoUploader } from '../../Editor/VideoUploader'
|
import { VideoUploader } from '../../Editor/VideoUploader'
|
||||||
|
@ -45,6 +46,7 @@ const GrowingTextarea = lazy(() => import('~/components/_shared/GrowingTextarea/
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
shout: Shout
|
shout: Shout
|
||||||
|
editor: Editor
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MAX_HEADER_LIMIT = 100
|
export const MAX_HEADER_LIMIT = 100
|
||||||
|
@ -456,10 +458,10 @@ export const EditView = (props: Props) => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Show when={form?.shoutId} fallback={<Loading />}>
|
<Show when={form?.shoutId} fallback={<Loading />}>
|
||||||
<Editor
|
<EditorComponent
|
||||||
shoutId={form.shoutId}
|
shoutId={form.shoutId}
|
||||||
initialContent={form.body}
|
initialContent={form.body}
|
||||||
onChange={(body) => handleInputChange('body', body)}
|
onChange={(body: string) => handleInputChange('body', body)}
|
||||||
/>
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import { useMatch, useNavigate } from '@solidjs/router'
|
import { useMatch, useNavigate } from '@solidjs/router'
|
||||||
|
import { Editor } from '@tiptap/core'
|
||||||
import type { JSX } from 'solid-js'
|
import type { JSX } from 'solid-js'
|
||||||
import { Accessor, createContext, createMemo, createSignal, useContext } from 'solid-js'
|
import { Accessor, createContext, createMemo, createSignal, useContext } from 'solid-js'
|
||||||
import { SetStoreFunction, createStore } from 'solid-js/store'
|
import { SetStoreFunction, createStore } from 'solid-js/store'
|
||||||
|
@ -33,7 +34,7 @@ export type ShoutForm = {
|
||||||
media?: string
|
media?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
type EditorContextType = {
|
export type EditorContextType = {
|
||||||
isEditorPanelVisible: Accessor<boolean>
|
isEditorPanelVisible: Accessor<boolean>
|
||||||
wordCounter: Accessor<WordCounter>
|
wordCounter: Accessor<WordCounter>
|
||||||
form: ShoutForm
|
form: ShoutForm
|
||||||
|
@ -49,9 +50,11 @@ type EditorContextType = {
|
||||||
countWords: (value: WordCounter) => void
|
countWords: (value: WordCounter) => void
|
||||||
setForm: SetStoreFunction<ShoutForm>
|
setForm: SetStoreFunction<ShoutForm>
|
||||||
setFormErrors: SetStoreFunction<Record<keyof ShoutForm, string>>
|
setFormErrors: SetStoreFunction<Record<keyof ShoutForm, string>>
|
||||||
|
editor: Accessor<Editor | undefined>
|
||||||
|
setEditor: (e: Editor) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const EditorContext = createContext<EditorContextType>({} as EditorContextType)
|
export const EditorContext = createContext<EditorContextType>({} as EditorContextType)
|
||||||
|
|
||||||
export function useEditorContext() {
|
export function useEditorContext() {
|
||||||
return useContext(EditorContext)
|
return useContext(EditorContext)
|
||||||
|
@ -83,7 +86,7 @@ export const EditorProvider = (props: { children: JSX.Element }) => {
|
||||||
const matchEditSettings = useMatch(() => '/editSettings')
|
const matchEditSettings = useMatch(() => '/editSettings')
|
||||||
const { session } = useSession()
|
const { session } = useSession()
|
||||||
const client = createMemo(() => graphqlClientCreate(coreApiUrl, session()?.access_token))
|
const client = createMemo(() => graphqlClientCreate(coreApiUrl, session()?.access_token))
|
||||||
|
const [editor, setEditor] = createSignal<Editor | undefined>()
|
||||||
const { addFeed } = useFeed()
|
const { addFeed } = useFeed()
|
||||||
const snackbar = useSnackbar()
|
const snackbar = useSnackbar()
|
||||||
const [isEditorPanelVisible, setIsEditorPanelVisible] = createSignal<boolean>(false)
|
const [isEditorPanelVisible, setIsEditorPanelVisible] = createSignal<boolean>(false)
|
||||||
|
@ -278,7 +281,9 @@ export const EditorProvider = (props: { children: JSX.Element }) => {
|
||||||
toggleEditorPanel,
|
toggleEditorPanel,
|
||||||
countWords,
|
countWords,
|
||||||
setForm,
|
setForm,
|
||||||
setFormErrors
|
setFormErrors,
|
||||||
|
editor,
|
||||||
|
setEditor
|
||||||
}
|
}
|
||||||
|
|
||||||
const value: EditorContextType = {
|
const value: EditorContextType = {
|
||||||
|
|
|
@ -15,7 +15,7 @@ import { processPrepositions } from '~/intl/prepositions'
|
||||||
|
|
||||||
i18nextInit()
|
i18nextInit()
|
||||||
|
|
||||||
type LocalizeContextType = {
|
export type LocalizeContextType = {
|
||||||
t: i18n['t']
|
t: i18n['t']
|
||||||
lang: Accessor<Language>
|
lang: Accessor<Language>
|
||||||
setLang: (lang: Language) => void
|
setLang: (lang: Language) => void
|
||||||
|
@ -26,7 +26,7 @@ type LocalizeContextType = {
|
||||||
|
|
||||||
export type Language = 'ru' | 'en'
|
export type Language = 'ru' | 'en'
|
||||||
|
|
||||||
const LocalizeContext = createContext<LocalizeContextType>({
|
export const LocalizeContext = createContext<LocalizeContextType>({
|
||||||
t: (s: string) => s
|
t: (s: string) => s
|
||||||
} as LocalizeContextType)
|
} as LocalizeContextType)
|
||||||
|
|
||||||
|
|
|
@ -85,7 +85,8 @@ const metaRes = {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const SessionContext = createContext<SessionContextType>({} as SessionContextType)
|
|
||||||
|
export const SessionContext = createContext<SessionContextType>({} as SessionContextType)
|
||||||
|
|
||||||
export function useSession() {
|
export function useSession() {
|
||||||
return useContext(SessionContext)
|
return useContext(SessionContext)
|
||||||
|
|
|
@ -17,12 +17,12 @@ type SnackbarMessage = {
|
||||||
duration?: number
|
duration?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
type SnackbarContextType = {
|
export type SnackbarContextType = {
|
||||||
snackbarMessage: Accessor<SnackbarMessage | null | undefined>
|
snackbarMessage: Accessor<SnackbarMessage | null | undefined>
|
||||||
showSnackbar: (message: SnackbarMessage) => Promise<void>
|
showSnackbar: (message: SnackbarMessage) => Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
const SnackbarContext = createContext<SnackbarContextType>({
|
export const SnackbarContext = createContext<SnackbarContextType>({
|
||||||
snackbarMessage: () => undefined,
|
snackbarMessage: () => undefined,
|
||||||
showSnackbar: async (_m: SnackbarMessage) => undefined
|
showSnackbar: async (_m: SnackbarMessage) => undefined
|
||||||
} as SnackbarContextType)
|
} as SnackbarContextType)
|
||||||
|
|
|
@ -1,6 +1,16 @@
|
||||||
export const allChars = /[^\dA-zА-я]/
|
export const notChar = /[^\dA-Za-zА-Яа-я]/
|
||||||
export const slugChars = /[^\da-z]/g
|
export const allChar = /[\dA-Za-zА-Яа-я]/
|
||||||
export const enChars = /[^A-z]/
|
export const notLatin = /[^A-Za-z]/
|
||||||
export const ruChars = /[^ËА-яё]/
|
export const notRus = /[^ËА-Яа-яё]/
|
||||||
export const sentenceSeparator = /{!|\?|:|;}\s/
|
export const sentenceSeparator = /{!|\?|:|;}\s/
|
||||||
export const cyrillicRegex = /[\u0400-\u04FF]/ // Range for Cyrillic characters
|
export const cyrillicRegex = /[\u0400-\u04FF]/ // Range for Cyrillic characters
|
||||||
|
|
||||||
|
export function findFirstReadableCharIndex(input: string): number {
|
||||||
|
for (let i = 0; i < input.length; i++) {
|
||||||
|
// Test each character against the "allChar" regex (readable characters).
|
||||||
|
if (allChar.test(input[i])) {
|
||||||
|
return i // Return the index of the first non-readable character
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0 // Return -1 if no non-readable characters are found
|
||||||
|
}
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { Author } from '~/graphql/schema/core.gen'
|
import { Author } from '~/graphql/schema/core.gen'
|
||||||
import { capitalize } from '~/utils/capitalize'
|
import { capitalize } from '~/utils/capitalize'
|
||||||
import { allChars, cyrillicRegex, enChars, ruChars } from './chars'
|
import { cyrillicRegex, findFirstReadableCharIndex, notChar, notLatin, notRus } from './chars'
|
||||||
import { translit } from './translit'
|
import { translit } from './translit'
|
||||||
|
|
||||||
export const isCyrillic = (s: string): boolean => {
|
export const isCyrillic = (s: string): boolean => {
|
||||||
|
@ -17,22 +17,35 @@ export const translateAuthor = (author: Author, lng: string) =>
|
||||||
|
|
||||||
export const authorLetterReduce = (acc: { [x: string]: Author[] }, author: Author, lng: string) => {
|
export const authorLetterReduce = (acc: { [x: string]: Author[] }, author: Author, lng: string) => {
|
||||||
let letter = ''
|
let letter = ''
|
||||||
if (!letter && author && author.name) {
|
|
||||||
const name =
|
|
||||||
translateAuthor(author, lng || 'ru')
|
|
||||||
?.replace(allChars, ' ')
|
|
||||||
.trim() || ''
|
|
||||||
const nameParts = name.trim().split(' ')
|
|
||||||
const found = nameParts.filter(Boolean).pop()
|
|
||||||
if (found && found.length > 0) {
|
|
||||||
letter = found[0].toUpperCase()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (ruChars.test(letter) && lng === 'ru') letter = '@'
|
|
||||||
if (enChars.test(letter) && lng === 'en') letter = '@'
|
|
||||||
|
|
||||||
|
if (author?.name) {
|
||||||
|
// Get the translated author name and clean it up
|
||||||
|
const name = translateAuthor(author, lng || 'ru')?.trim() || ''
|
||||||
|
const nameParts = name.split(' ')
|
||||||
|
const lastName = nameParts.filter(Boolean).pop()?.replace(notChar, ' ').trim() || '' // Replace non-readable characters
|
||||||
|
|
||||||
|
// Get the last part of the name
|
||||||
|
if (lastName && lastName.length > 0) {
|
||||||
|
const firstCharIndex = findFirstReadableCharIndex(lastName)
|
||||||
|
|
||||||
|
// Make sure the index is valid before accessing the character
|
||||||
|
if (firstCharIndex !== -1) {
|
||||||
|
letter = lastName[firstCharIndex].toUpperCase()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle non-readable letters based on the language
|
||||||
|
if (notRus.test(letter) && lng === 'ru') letter = '@'
|
||||||
|
if (notLatin.test(letter) && lng === 'en') letter = '@'
|
||||||
|
|
||||||
|
// Initialize the letter group if it doesn't exist
|
||||||
if (!acc[letter]) acc[letter] = []
|
if (!acc[letter]) acc[letter] = []
|
||||||
|
|
||||||
|
// Translate the author's name for the current language
|
||||||
author.name = translateAuthor(author, lng)
|
author.name = translateAuthor(author, lng)
|
||||||
|
|
||||||
|
// Push the author into the corresponding letter group
|
||||||
acc[letter].push(author)
|
acc[letter].push(author)
|
||||||
|
|
||||||
// Sort authors within each letter group alphabetically by name
|
// Sort authors within each letter group alphabetically by name
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import translitConfig from './abc-translit.json'
|
import translitConfig from './abc-translit.json'
|
||||||
import { ruChars, slugChars } from './chars'
|
import { notChar, notLatin } from './chars'
|
||||||
|
|
||||||
const ru2en: { [key: string]: string } = translitConfig
|
const ru2en: { [key: string]: string } = translitConfig
|
||||||
|
|
||||||
|
@ -8,7 +8,7 @@ export const translit = (str: string) => {
|
||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
const isCyrillic = ruChars.test(str)
|
const isCyrillic = notLatin.test(str)
|
||||||
|
|
||||||
if (!isCyrillic) {
|
if (!isCyrillic) {
|
||||||
return str
|
return str
|
||||||
|
@ -18,5 +18,5 @@ export const translit = (str: string) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const slugify = (text: string) => {
|
export const slugify = (text: string) => {
|
||||||
return translit(text.toLowerCase()).replaceAll(' ', '-').replaceAll(slugChars, '')
|
return translit(text.toLowerCase()).replaceAll(' ', '-').replaceAll(notChar, '')
|
||||||
}
|
}
|
||||||
|
|
|
@ -24,7 +24,7 @@ const polyfillOptions = {
|
||||||
export default {
|
export default {
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
'~': path.resolve(__dirname, './src')
|
'~': path.resolve('./src')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
envPrefix: 'PUBLIC_',
|
envPrefix: 'PUBLIC_',
|
||||||
|
|
Loading…
Reference in New Issue
Block a user