diff --git a/src/components/Article/CommentsTree.tsx b/src/components/Article/CommentsTree.tsx index db408b39..e53a9f39 100644 --- a/src/components/Article/CommentsTree.tsx +++ b/src/components/Article/CommentsTree.tsx @@ -158,11 +158,11 @@ export const CommentsTree = (props: Props) => { handleSubmitComment(value)} - reset={clearEditor()} + setClear={clearEditor()} isPosting={posting()} /> diff --git a/src/components/Editor/Editor.stories.tsx b/src/components/Editor/Editor.stories.tsx new file mode 100644 index 00000000..a97727a3 --- /dev/null +++ b/src/components/Editor/Editor.stories.tsx @@ -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({ + body: '', + slug: '', + shoutId: 0, + title: '', + selectedTopics: [] +}) +const [_formErrors, setFormErrors] = createStore({} as Record) +const [editor, setEditor] = createSignal() +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, + setEditor, + editor, + saveShout: (_form: ShoutForm): Promise => { + throw new Error('Function not implemented.') + }, + saveDraft: (_form: ShoutForm): Promise => { + 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 => { + throw new Error('Function not implemented.') + }, + publishShoutById: (_shoutId: number): Promise => { + throw new Error('Function not implemented.') + }, + deleteShout: (_shoutId: number): Promise => { + throw new Error('Function not implemented.') + }, + toggleEditorPanel: (): void => { + throw new Error('Function not implemented.') + }, + setForm, + setFormErrors +} + +const mockSnackbarContext = { + showSnackbar: console.log +} + +const meta: Meta = { + 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 + +export const Default: Story = { + render: (args) => { + const [_content, setContent] = createSignal(args.initialContent || '') + + return ( + + + + + { + args.onChange(text) + setContent(text) + }} + /> + + + + + ) + }, + args: { + shoutId: 1, + initialContent: '', + disableCollaboration: true + } +} + +export const WithInitialContent: Story = { + ...Default, + args: { + ...Default.args, + initialContent: '

This is some initial content in the editor.

' + } +} diff --git a/src/components/Editor/Editor.tsx b/src/components/Editor/Editor.tsx index a2082a1d..6bf4d1d9 100644 --- a/src/components/Editor/Editor.tsx +++ b/src/components/Editor/Editor.tsx @@ -1,5 +1,5 @@ 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 { BubbleMenu } from '@tiptap/extension-bubble-menu' import { BulletList } from '@tiptap/extension-bullet-list' @@ -26,7 +26,7 @@ import { Strike } from '@tiptap/extension-strike' import { Text } from '@tiptap/extension-text' import { Underline } from '@tiptap/extension-underline' 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 { Doc } from 'yjs' import { useEditorContext } from '~/context/editor' @@ -47,14 +47,15 @@ import { Iframe } from './extensions/Iframe' import { Span } from './extensions/Span' import { ToggleTextWrap } from './extensions/ToggleTextWrap' import { TrailingNode } from './extensions/TrailingNode' +import { renderUploadedImage } from './renderUploadedImage' import './Prosemirror.scss' -import { renderUploadedImage } from './renderUploadedImage' type Props = { shoutId: number initialContent?: string onChange: (text: string) => void + disableCollaboration?: boolean } const allowedImageTypes = new Set([ @@ -78,28 +79,14 @@ export const EditorComponent = (props: Props) => { const [isCommonMarkup, setIsCommonMarkup] = createSignal(false) const [shouldShowTextBubbleMenu, setShouldShowTextBubbleMenu] = createSignal(false) const { showSnackbar } = useSnackbar() - - const docName = `shout-${props.shoutId}` - - 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: session()?.access_token || '' - }) - } - - const [editorElRef, setEditorElRef] = createSignal() - let textBubbleMenuRef: HTMLDivElement | undefined - let incutBubbleMenuRef: HTMLElement | undefined - let figureBubbleMenuRef: HTMLElement | undefined - let blockquoteBubbleMenuRef: HTMLElement | undefined - let floatingMenuRef: HTMLDivElement | undefined + const { setEditor, countWords } = useEditorContext() + const [extensions, setExtensions] = createSignal([]) + const [editorElRef, setEditorElRef] = createSignal() + const [textBubbleMenuRef, setTextBubbleMenuRef] = createSignal() + const [incutBubbleMenuRef, setIncutBubbleMenuRef] = createSignal() + const [figureBubbleMenuRef, setFigureBubbleMenuRef] = createSignal() + const [blockquoteBubbleMenuRef, setBlockquoteBubbleMenuRef] = createSignal() + const [floatingMenuRef, setFloatingMenuRef] = createSignal() const handleClipboardPaste = async () => { try { @@ -130,174 +117,186 @@ export const EditorComponent = (props: Props) => { } } - const { initialContent } = props - const { editor, setEditor, countWords } = useEditorContext() + const editor = createTiptapEditor(() => ({ + element: editorElRef()!, + editorProps: { + attributes: { + class: 'articleEditor' + }, + transformPastedHTML(html) { + return html.replaceAll(//g, '') + }, + handlePaste: () => { + handleClipboardPaste() + return false + } + }, + 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(editorElRef, (ee: HTMLElement | undefined) => { - if (ee) { - const freshEditor = createTiptapEditor(() => ({ - element: ee, - editorProps: { - attributes: { - class: 'articleEditor' + on( + [extensions, editorElRef, author, () => `shout-${props.shoutId}`], + ([eee, element, a, docName]) => + eee.length === 0 && + a && + element && + setExtensions([ + Document, + Text, + Paragraph, + Bold, + Italic, + Strike, + Heading.configure({ levels: [2, 3, 4] }), + BulletList, + OrderedList, + ListItem, + + 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({ + provider: providers[docName], + user: { name: a.name, color: uniqolor(a.slug).color } + }), + Placeholder.configure({ placeholder: t('Add a link or click plus to embed media') }), + Focus, + Gapcursor, + HardBreak, + Highlight.configure({ multicolor: true, HTMLAttributes: { class: 'highlight' } }), + Image, + Iframe, + Figure, + Figcaption, + Footnote, + ToggleTextWrap, + CharacterCount.configure(), // https://github.com/ueberdosis/tiptap/issues/2589#issuecomment-1093084689 + BubbleMenu.configure({ + pluginKey: 'textBubbleMenu', + element: textBubbleMenuRef(), + shouldShow: ({ editor: e, view, state: { doc, selection }, from, to }) => { + const isEmptyTextBlock = doc.textBetween(from, to).length === 0 && isTextSelection(selection) + isEmptyTextBlock && + e?.chain().focus().removeTextWrap({ class: 'highlight-fake-selection' }).run() + + setIsCommonMarkup(e?.isActive('figcaption')) + const result = + (view.hasFocus() && + !selection.empty && + !isEmptyTextBlock && + !e.isActive('image') && + !e.isActive('figure')) || + e.isActive('footnote') || + (e.isActive('figcaption') && !selection.empty) + setShouldShowTextBubbleMenu(result) + return result }, - transformPastedHTML(html) { - return html.replaceAll(//g, '') - }, - handlePaste: () => { - handleClipboardPaste() - return false + tippyOptions: { + onHide: () => editor()?.commands.focus() as false } - }, - extensions: [ - Document, - Text, - Paragraph, - Dropcursor, - CustomBlockquote, - Bold, - Italic, - Span, - ToggleTextWrap, - Strike, - HorizontalRule.configure({ - HTMLAttributes: { - class: 'horizontalRule' - } - }), - Underline, - Link.extend({ - inclusive: false - }).configure({ - autolink: true, - openOnClick: false - }), - Heading.configure({ - levels: [2, 3, 4] - }), - BulletList, - OrderedList, - ListItem, - Collaboration.configure({ - document: yDocs[docName] - }), + }), + BubbleMenu.configure({ + pluginKey: 'blockquoteBubbleMenu', + element: blockquoteBubbleMenuRef(), + shouldShow: ({ editor: e, view, state }) => + view.hasFocus() && !state.selection.empty && e.isActive('blockquote') + }), + BubbleMenu.configure({ + pluginKey: 'figureBubbleMenu', + element: figureBubbleMenuRef(), + shouldShow: ({ editor: e, view, state }) => + view.hasFocus() && !state.selection.empty && e.isActive('figure') + }), + BubbleMenu.configure({ + pluginKey: 'incutBubbleMenu', + element: incutBubbleMenuRef(), + shouldShow: ({ editor: e, view, state }) => + view.hasFocus() && !state.selection.empty && e.isActive('figcaption') + }), + FloatingMenu.configure({ + element: floatingMenuRef(), + pluginKey: 'floatingMenu', + shouldShow: ({ editor: e, state: { selection } }) => { + const isRootDepth = selection.$anchor.depth === 1 + if (!(isRootDepth && selection.empty)) return false + return !(e.isActive('codeBlock') || e.isActive('heading')) + } + }), + TrailingNode, + ArticleNode + ]) + ) + ) + + createEffect( + on( + [ + () => !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: author().name, - color: uniqolor(author().slug).color + name: profile.name, + color: uniqolor(profile.slug).color } - }), - Placeholder.configure({ - placeholder: t('Add a link or click plus to embed media') - }), - Focus, - Gapcursor, - HardBreak, - Highlight.configure({ - multicolor: true, - HTMLAttributes: { - class: 'highlight' - } - }), - Image, - Iframe, - Figure, - Figcaption, - Footnote, - ToggleTextWrap, - CharacterCount.configure(), // https://github.com/ueberdosis/tiptap/issues/2589#issuecomment-1093084689 - BubbleMenu.configure({ - pluginKey: 'textBubbleMenu', - element: textBubbleMenuRef, - shouldShow: ({ editor: e, view, state, from, to }) => { - const { doc, selection } = state - const { empty } = selection - const isEmptyTextBlock = - doc.textBetween(from, to).length === 0 && isTextSelection(selection) - if (isEmptyTextBlock) { - e?.chain().focus().removeTextWrap({ class: 'highlight-fake-selection' }).run() - } - setIsCommonMarkup(e?.isActive('figcaption')) - const result = - (view.hasFocus() && - !empty && - !isEmptyTextBlock && - !e.isActive('image') && - !e.isActive('figure')) || - e.isActive('footnote') || - (e.isActive('figcaption') && !empty) - setShouldShowTextBubbleMenu(result) - return result - }, - tippyOptions: { - onHide: () => { - const fe = freshEditor() as Editor - fe?.commands.focus() - } - } - }), - BubbleMenu.configure({ - pluginKey: 'blockquoteBubbleMenu', - element: blockquoteBubbleMenuRef, - shouldShow: ({ editor: e, view, state }) => { - const { empty } = state.selection - return view.hasFocus() && !empty && e.isActive('blockquote') - } - }), - BubbleMenu.configure({ - pluginKey: 'figureBubbleMenu', - element: figureBubbleMenuRef, - shouldShow: ({ editor: e, view, state }) => { - const { empty } = state.selection - return view.hasFocus() && !empty && e.isActive('figure') - } - }), - BubbleMenu.configure({ - pluginKey: 'incutBubbleMenu', - element: incutBubbleMenuRef, - shouldShow: ({ editor: e, view, state }) => { - const { empty } = state.selection - return view.hasFocus() && !empty && e.isActive('figcaption') - } - }), - FloatingMenu.configure({ - element: floatingMenuRef, - pluginKey: 'floatingMenu', - shouldShow: ({ editor: e, state }) => { - const { $anchor, empty } = state.selection - const isRootDepth = $anchor.depth === 1 - - if (!(isRootDepth && empty)) return false - - return !(e.isActive('codeBlock') || e.isActive('heading')) - } - }), - TrailingNode, - 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) { - editorElRef()?.addEventListener('focus', (_event) => { - if (freshEditor()?.isActive('figcaption')) { - freshEditor()?.commands.focus() - } - }) - setEditor(freshEditor() as Editor) - } + }) + ]) } + ) + ) + + createEffect( + on(editorElRef, (ee: HTMLElement | undefined) => { + ee?.addEventListener('focus', (_event) => { + if (editor()?.isActive('figcaption')) { + editor()?.commands.focus() + } + }) }) ) @@ -318,27 +317,12 @@ export const EditorComponent = (props: Props) => { shouldShow={shouldShowTextBubbleMenu()} isCommonMarkup={isCommonMarkup()} editor={editor() as Editor} - ref={(el) => (textBubbleMenuRef = el)} + ref={setTextBubbleMenuRef} /> - { - blockquoteBubbleMenuRef = el - }} - editor={editor() as Editor} - /> - { - figureBubbleMenuRef = el - }} - /> - { - incutBubbleMenuRef = el - }} - /> - (floatingMenuRef = el)} /> + + + + ) diff --git a/src/components/Editor/Panel/Panel.tsx b/src/components/Editor/Panel/Panel.tsx index 6427487a..3afbce20 100644 --- a/src/components/Editor/Panel/Panel.tsx +++ b/src/components/Editor/Panel/Panel.tsx @@ -25,12 +25,12 @@ export const Panel = (props: Props) => { const { isEditorPanelVisible, wordCounter, - editor, form, toggleEditorPanel, saveShout, saveDraft, - publishShout + publishShout, + editor } = useEditorContext() 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 = () => { editor()?.commands.setContent(typograf.execute(html() || '')) // here too diff --git a/src/components/Editor/SimplifiedEditor.tsx b/src/components/Editor/SimplifiedEditor.tsx index f857cf6b..de656fb7 100644 --- a/src/components/Editor/SimplifiedEditor.tsx +++ b/src/components/Editor/SimplifiedEditor.tsx @@ -1,3 +1,4 @@ +import { Editor } from '@tiptap/core' import { Blockquote } from '@tiptap/extension-blockquote' import { BubbleMenu } from '@tiptap/extension-bubble-menu' import { CharacterCount } from '@tiptap/extension-character-count' @@ -14,12 +15,14 @@ import { useEditorIsEmpty, useEditorIsFocused } from 'solid-tiptap' - import { useLocalize } from '~/context/localize' +import { useUI } from '~/context/ui' +import { base } from '~/lib/editorOptions' import { UploadedFile } from '~/types/upload' import { Button } from '../_shared/Button' import { Icon } from '../_shared/Icon' import { Loading } from '../_shared/Loading' +import { Modal } from '../_shared/Modal/Modal' import { Popover } from '../_shared/Popover' import { ShowOnlyOnClient } from '../_shared/ShowOnlyOnClient' import { LinkBubbleMenuModule } from './LinkBubbleMenu' @@ -27,14 +30,10 @@ import { TextBubbleMenu } from './TextBubbleMenu' import { UploadModalContent } from './UploadModalContent' import { Figcaption } from './extensions/Figcaption' 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 styles from './SimplifiedEditor.module.scss' + type Props = { placeholder: string initialContent?: string diff --git a/src/components/TopicsNav/TopicsNav.tsx b/src/components/TopicsNav/TopicsNav.tsx index b0818bb4..ac8b0a3f 100644 --- a/src/components/TopicsNav/TopicsNav.tsx +++ b/src/components/TopicsNav/TopicsNav.tsx @@ -5,7 +5,7 @@ import { Icon } from '~/components/_shared/Icon' import { useLocalize } from '~/context/localize' import { useTopics } from '~/context/topics' import type { Topic } from '~/graphql/schema/core.gen' -import { ruChars } from '~/intl/chars' +import { notLatin } from '~/intl/chars' import { getRandomItemsFromArray } from '~/utils/random' import styles from './TopicsNav.module.scss' @@ -13,7 +13,7 @@ export const RandomTopics = () => { const { sortedTopics } = useTopics() const { lang, t } = useLocalize() 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([]) createEffect( on(sortedTopics, (ttt: Topic[]) => { diff --git a/src/components/Views/AllAuthors/AllAuthors.tsx b/src/components/Views/AllAuthors/AllAuthors.tsx index 64462ae3..762bca00 100644 --- a/src/components/Views/AllAuthors/AllAuthors.tsx +++ b/src/components/Views/AllAuthors/AllAuthors.tsx @@ -11,7 +11,6 @@ import { useLocalize } from '~/context/localize' import type { Author } from '~/graphql/schema/core.gen' import { dummyFilter } from '~/intl/dummyFilter' import { authorLetterReduce, translateAuthor } from '~/intl/translate' -// import { byFirstChar, byStat } from '~/lib/sort' import { scrollHandler } from '~/utils/scroll' import styles from './AllAuthors.module.scss' import stylesAuthorList from './AuthorsList.module.scss' @@ -25,8 +24,8 @@ type Props = { export const AUTHORS_PER_PAGE = 20 export const ABC = { - ru: 'АБВГДЕЁЖЗИЙКЛМНОПРСТУФХЦЧШЩЪЫЬЭЮЯ#', - en: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ#' + ru: 'АБВГДЕЁЖЗИЙКЛМНОПРСТУФХЦЧШЩЪЫЬЭЮЯ@', + en: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ@' } // useAuthors sorted from context, set filter/sort diff --git a/src/components/Views/AllTopics/AllTopics.tsx b/src/components/Views/AllTopics/AllTopics.tsx index d1e9421e..89bc1b79 100644 --- a/src/components/Views/AllTopics/AllTopics.tsx +++ b/src/components/Views/AllTopics/AllTopics.tsx @@ -6,8 +6,9 @@ import { SearchField } from '~/components/_shared/SearchField' import { useLocalize } from '~/context/localize' import { useTopics } from '~/context/topics' 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 { capitalize } from '~/utils/capitalize' import { scrollHandler } from '~/utils/scroll' import { TopicBadge } from '../../Topic/TopicBadge' import styles from './AllTopics.module.scss' @@ -28,16 +29,20 @@ export const AllTopics = (props: Props) => { const { setTopicsSort, sortedTopics } = useTopics() const topics = createMemo(() => sortedTopics() || props.topics) const [searchParams, changeSearchParams] = useSearchParams<{ by?: string }>() + onMount(() => changeSearchParams({ by: 'shouts' })) createEffect(on(() => searchParams?.by || 'shouts', setTopicsSort, { defer: true })) - onMount(() => !searchParams?.by && changeSearchParams({ by: 'shouts' })) // sorted derivative const byLetter = createMemo<{ [letter: string]: Topic[] }>(() => { return topics().reduce( (acc, topic) => { - let letter = lang() === 'en' ? topic.slug[0].toUpperCase() : (topic?.title?.[0] || '').toUpperCase() - if (enChars.test(letter) && lang() === 'ru') letter = '#' - if (ruChars.test(letter) && lang() === 'en') letter = '#' + const firstCharIndex = findFirstReadableCharIndex(topic?.title || '') + let 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] = [] acc[letter].push(topic) return acc @@ -124,7 +129,9 @@ export const AllTopics = (props: Props) => { {(topic) => (
- {topic.title || topic.slug} + + {lang() !== 'ru' ? capitalize(topic.slug.replaceAll('-', ' ')) : topic.title} + {topic.stat?.shouts || 0} diff --git a/src/components/Views/EditView/EditSettingsView.tsx b/src/components/Views/EditView/EditSettingsView.tsx index 2f764391..a410f9a4 100644 --- a/src/components/Views/EditView/EditSettingsView.tsx +++ b/src/components/Views/EditView/EditSettingsView.tsx @@ -46,7 +46,6 @@ export const EditSettingsView = (props: Props) => { const [isScrolled, setIsScrolled] = createSignal(false) const { session } = useSession() const client = createMemo(() => graphqlClientCreate(coreApiUrl, session()?.access_token)) - const { form, setForm, saveDraft, saveDraftToLocalStorage, getDraftFromLocalStorage } = useEditorContext() const [shoutTopics, setShoutTopics] = createSignal([]) const [draft, setDraft] = createSignal() diff --git a/src/components/Views/EditView/EditView.tsx b/src/components/Views/EditView/EditView.tsx index 12811287..67fc3bbb 100644 --- a/src/components/Views/EditView/EditView.tsx +++ b/src/components/Views/EditView/EditView.tsx @@ -1,3 +1,4 @@ +import { Editor } from '@tiptap/core' import { clsx } from 'clsx' import deepEqual from 'fast-deep-equal' import { @@ -32,7 +33,7 @@ import { isDesktop } from '~/lib/mediaQuery' import { LayoutType } from '~/types/common' import { MediaItem } from '~/types/mediaitem' import { clone } from '~/utils/clone' -import { Editor, Panel } from '../../Editor' +import { Editor as EditorComponent, Panel } from '../../Editor' import { AudioUploader } from '../../Editor/AudioUploader' import { AutoSaveNotice } from '../../Editor/AutoSaveNotice' import { VideoUploader } from '../../Editor/VideoUploader' @@ -45,6 +46,7 @@ const GrowingTextarea = lazy(() => import('~/components/_shared/GrowingTextarea/ type Props = { shout: Shout + editor: Editor } export const MAX_HEADER_LIMIT = 100 @@ -456,10 +458,10 @@ export const EditView = (props: Props) => {
}> - handleInputChange('body', body)} + onChange={(body: string) => handleInputChange('body', body)} /> diff --git a/src/context/editor.tsx b/src/context/editor.tsx index 5e2d3a43..1d9b6c22 100644 --- a/src/context/editor.tsx +++ b/src/context/editor.tsx @@ -1,4 +1,5 @@ import { useMatch, useNavigate } from '@solidjs/router' +import { Editor } from '@tiptap/core' import type { JSX } from 'solid-js' import { Accessor, createContext, createMemo, createSignal, useContext } from 'solid-js' import { SetStoreFunction, createStore } from 'solid-js/store' @@ -33,7 +34,7 @@ export type ShoutForm = { media?: string } -type EditorContextType = { +export type EditorContextType = { isEditorPanelVisible: Accessor wordCounter: Accessor form: ShoutForm @@ -49,9 +50,11 @@ type EditorContextType = { countWords: (value: WordCounter) => void setForm: SetStoreFunction setFormErrors: SetStoreFunction> + editor: Accessor + setEditor: (e: Editor) => void } -const EditorContext = createContext({} as EditorContextType) +export const EditorContext = createContext({} as EditorContextType) export function useEditorContext() { return useContext(EditorContext) @@ -83,7 +86,7 @@ export const EditorProvider = (props: { children: JSX.Element }) => { const matchEditSettings = useMatch(() => '/editSettings') const { session } = useSession() const client = createMemo(() => graphqlClientCreate(coreApiUrl, session()?.access_token)) - + const [editor, setEditor] = createSignal() const { addFeed } = useFeed() const snackbar = useSnackbar() const [isEditorPanelVisible, setIsEditorPanelVisible] = createSignal(false) @@ -278,7 +281,9 @@ export const EditorProvider = (props: { children: JSX.Element }) => { toggleEditorPanel, countWords, setForm, - setFormErrors + setFormErrors, + editor, + setEditor } const value: EditorContextType = { diff --git a/src/context/localize.tsx b/src/context/localize.tsx index db4f93f0..e9b8a883 100644 --- a/src/context/localize.tsx +++ b/src/context/localize.tsx @@ -15,7 +15,7 @@ import { processPrepositions } from '~/intl/prepositions' i18nextInit() -type LocalizeContextType = { +export type LocalizeContextType = { t: i18n['t'] lang: Accessor setLang: (lang: Language) => void @@ -26,7 +26,7 @@ type LocalizeContextType = { export type Language = 'ru' | 'en' -const LocalizeContext = createContext({ +export const LocalizeContext = createContext({ t: (s: string) => s } as LocalizeContextType) diff --git a/src/context/session.tsx b/src/context/session.tsx index c3e8fa87..12a3f072 100644 --- a/src/context/session.tsx +++ b/src/context/session.tsx @@ -85,7 +85,8 @@ const metaRes = { } } } -const SessionContext = createContext({} as SessionContextType) + +export const SessionContext = createContext({} as SessionContextType) export function useSession() { return useContext(SessionContext) diff --git a/src/context/ui.tsx b/src/context/ui.tsx index d0c30cf2..f3114032 100644 --- a/src/context/ui.tsx +++ b/src/context/ui.tsx @@ -17,12 +17,12 @@ type SnackbarMessage = { duration?: number } -type SnackbarContextType = { +export type SnackbarContextType = { snackbarMessage: Accessor showSnackbar: (message: SnackbarMessage) => Promise } -const SnackbarContext = createContext({ +export const SnackbarContext = createContext({ snackbarMessage: () => undefined, showSnackbar: async (_m: SnackbarMessage) => undefined } as SnackbarContextType) diff --git a/src/intl/chars.ts b/src/intl/chars.ts index 37e85f66..d122e2ff 100644 --- a/src/intl/chars.ts +++ b/src/intl/chars.ts @@ -1,6 +1,16 @@ -export const allChars = /[^\dA-zА-я]/ -export const slugChars = /[^\da-z]/g -export const enChars = /[^A-z]/ -export const ruChars = /[^ËА-яё]/ +export const notChar = /[^\dA-Za-zА-Яа-я]/ +export const allChar = /[\dA-Za-zА-Яа-я]/ +export const notLatin = /[^A-Za-z]/ +export const notRus = /[^ËА-Яа-яё]/ export const sentenceSeparator = /{!|\?|:|;}\s/ 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 +} diff --git a/src/intl/translate.ts b/src/intl/translate.ts index 6440b64e..af0457f0 100644 --- a/src/intl/translate.ts +++ b/src/intl/translate.ts @@ -1,6 +1,6 @@ import { Author } from '~/graphql/schema/core.gen' import { capitalize } from '~/utils/capitalize' -import { allChars, cyrillicRegex, enChars, ruChars } from './chars' +import { cyrillicRegex, findFirstReadableCharIndex, notChar, notLatin, notRus } from './chars' import { translit } from './translit' 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) => { 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 (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() + } } } - if (ruChars.test(letter) && lng === 'ru') letter = '@' - if (enChars.test(letter) && lng === 'en') letter = '@' + // 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] = [] + + // Translate the author's name for the current language author.name = translateAuthor(author, lng) + + // Push the author into the corresponding letter group acc[letter].push(author) // Sort authors within each letter group alphabetically by name diff --git a/src/intl/translit.ts b/src/intl/translit.ts index 2f71bf29..d5d79278 100644 --- a/src/intl/translit.ts +++ b/src/intl/translit.ts @@ -1,5 +1,5 @@ import translitConfig from './abc-translit.json' -import { ruChars, slugChars } from './chars' +import { notChar, notLatin } from './chars' const ru2en: { [key: string]: string } = translitConfig @@ -8,7 +8,7 @@ export const translit = (str: string) => { return '' } - const isCyrillic = ruChars.test(str) + const isCyrillic = notLatin.test(str) if (!isCyrillic) { return str @@ -18,5 +18,5 @@ export const translit = (str: string) => { } export const slugify = (text: string) => { - return translit(text.toLowerCase()).replaceAll(' ', '-').replaceAll(slugChars, '') + return translit(text.toLowerCase()).replaceAll(' ', '-').replaceAll(notChar, '') } diff --git a/vite.config.ts b/vite.config.ts index 287a3256..ce346669 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -24,7 +24,7 @@ const polyfillOptions = { export default { resolve: { alias: { - '~': path.resolve(__dirname, './src') + '~': path.resolve('./src') } }, envPrefix: 'PUBLIC_',