abc-sort-fix
This commit is contained in:
parent
433a74a58a
commit
6e0a830168
|
@ -158,11 +158,11 @@ export const CommentsTree = (props: Props) => {
|
|||
<SimplifiedEditor
|
||||
quoteEnabled={true}
|
||||
imageEnabled={true}
|
||||
options={{ autofocus: false }}
|
||||
autoFocus={false}
|
||||
submitByCtrlEnter={true}
|
||||
placeholder={t('Write a comment...')}
|
||||
onSubmit={(value) => handleSubmitComment(value)}
|
||||
reset={clearEditor()}
|
||||
setClear={clearEditor()}
|
||||
isPosting={posting()}
|
||||
/>
|
||||
</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 { 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<HTMLElement>()
|
||||
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<EditorOptions['extensions']>([])
|
||||
const [editorElRef, setEditorElRef] = createSignal<HTMLElement | undefined>()
|
||||
const [textBubbleMenuRef, setTextBubbleMenuRef] = createSignal<HTMLDivElement | undefined>()
|
||||
const [incutBubbleMenuRef, setIncutBubbleMenuRef] = createSignal<HTMLElement | undefined>()
|
||||
const [figureBubbleMenuRef, setFigureBubbleMenuRef] = createSignal<HTMLElement | undefined>()
|
||||
const [blockquoteBubbleMenuRef, setBlockquoteBubbleMenuRef] = createSignal<HTMLElement | undefined>()
|
||||
const [floatingMenuRef, setFloatingMenuRef] = createSignal<HTMLDivElement | undefined>()
|
||||
|
||||
const handleClipboardPaste = async () => {
|
||||
try {
|
||||
|
@ -130,13 +117,8 @@ export const EditorComponent = (props: Props) => {
|
|||
}
|
||||
}
|
||||
|
||||
const { initialContent } = props
|
||||
const { editor, setEditor, countWords } = useEditorContext()
|
||||
createEffect(
|
||||
on(editorElRef, (ee: HTMLElement | undefined) => {
|
||||
if (ee) {
|
||||
const freshEditor = createTiptapEditor<HTMLElement>(() => ({
|
||||
element: ee,
|
||||
const editor = createTiptapEditor(() => ({
|
||||
element: editorElRef()!,
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class: 'articleEditor'
|
||||
|
@ -149,57 +131,63 @@ export const EditorComponent = (props: Props) => {
|
|||
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,
|
||||
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]
|
||||
}),
|
||||
Heading.configure({ levels: [2, 3, 4] }),
|
||||
BulletList,
|
||||
OrderedList,
|
||||
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({
|
||||
provider: providers[docName],
|
||||
user: {
|
||||
name: author().name,
|
||||
color: uniqolor(author().slug).color
|
||||
}
|
||||
}),
|
||||
Placeholder.configure({
|
||||
placeholder: t('Add a link or click plus to embed media')
|
||||
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'
|
||||
}
|
||||
}),
|
||||
Highlight.configure({ multicolor: true, HTMLAttributes: { class: 'highlight' } }),
|
||||
Image,
|
||||
Iframe,
|
||||
Figure,
|
||||
|
@ -209,96 +197,107 @@ export const EditorComponent = (props: Props) => {
|
|||
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) {
|
||||
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() &&
|
||||
!empty &&
|
||||
!selection.empty &&
|
||||
!isEmptyTextBlock &&
|
||||
!e.isActive('image') &&
|
||||
!e.isActive('figure')) ||
|
||||
e.isActive('footnote') ||
|
||||
(e.isActive('figcaption') && !empty)
|
||||
(e.isActive('figcaption') && !selection.empty)
|
||||
setShouldShowTextBubbleMenu(result)
|
||||
return result
|
||||
},
|
||||
tippyOptions: {
|
||||
onHide: () => {
|
||||
const fe = freshEditor() as Editor
|
||||
fe?.commands.focus()
|
||||
}
|
||||
onHide: () => editor()?.commands.focus() as false
|
||||
}
|
||||
}),
|
||||
BubbleMenu.configure({
|
||||
pluginKey: 'blockquoteBubbleMenu',
|
||||
element: blockquoteBubbleMenuRef,
|
||||
shouldShow: ({ editor: e, view, state }) => {
|
||||
const { empty } = state.selection
|
||||
return view.hasFocus() && !empty && e.isActive('blockquote')
|
||||
}
|
||||
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 }) => {
|
||||
const { empty } = state.selection
|
||||
return view.hasFocus() && !empty && e.isActive('figure')
|
||||
}
|
||||
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 }) => {
|
||||
const { empty } = state.selection
|
||||
return view.hasFocus() && !empty && e.isActive('figcaption')
|
||||
}
|
||||
element: incutBubbleMenuRef(),
|
||||
shouldShow: ({ editor: e, view, state }) =>
|
||||
view.hasFocus() && !state.selection.empty && e.isActive('figcaption')
|
||||
}),
|
||||
FloatingMenu.configure({
|
||||
element: floatingMenuRef,
|
||||
element: floatingMenuRef(),
|
||||
pluginKey: 'floatingMenu',
|
||||
shouldShow: ({ editor: e, state }) => {
|
||||
const { $anchor, empty } = state.selection
|
||||
const isRootDepth = $anchor.depth === 1
|
||||
|
||||
if (!(isRootDepth && empty)) return false
|
||||
|
||||
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
|
||||
],
|
||||
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()
|
||||
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: 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(() => {
|
||||
|
@ -318,27 +317,12 @@ export const EditorComponent = (props: Props) => {
|
|||
shouldShow={shouldShowTextBubbleMenu()}
|
||||
isCommonMarkup={isCommonMarkup()}
|
||||
editor={editor() as Editor}
|
||||
ref={(el) => (textBubbleMenuRef = el)}
|
||||
ref={setTextBubbleMenuRef}
|
||||
/>
|
||||
<BlockquoteBubbleMenu
|
||||
ref={(el) => {
|
||||
blockquoteBubbleMenuRef = el
|
||||
}}
|
||||
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)} />
|
||||
<BlockquoteBubbleMenu ref={setBlockquoteBubbleMenuRef} editor={editor() as Editor} />
|
||||
<FigureBubbleMenu editor={editor() as Editor} ref={setFigureBubbleMenuRef} />
|
||||
<IncutBubbleMenu editor={editor() as Editor} ref={setIncutBubbleMenuRef} />
|
||||
<EditorFloatingMenu editor={editor() as Editor} ref={setFloatingMenuRef} />
|
||||
</Show>
|
||||
</>
|
||||
)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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<Topic[]>([])
|
||||
createEffect(
|
||||
on(sortedTopics, (ttt: Topic[]) => {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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) => {
|
|||
<For each={byLetter()[letter]}>
|
||||
{(topic) => (
|
||||
<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}>
|
||||
<span class={styles.articlesCounter}>{topic.stat?.shouts || 0}</span>
|
||||
</Show>
|
||||
|
|
|
@ -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<Topic[]>([])
|
||||
const [draft, setDraft] = createSignal()
|
||||
|
|
|
@ -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) => {
|
|||
</div>
|
||||
</div>
|
||||
<Show when={form?.shoutId} fallback={<Loading />}>
|
||||
<Editor
|
||||
<EditorComponent
|
||||
shoutId={form.shoutId}
|
||||
initialContent={form.body}
|
||||
onChange={(body) => handleInputChange('body', body)}
|
||||
onChange={(body: string) => handleInputChange('body', body)}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
|
|
|
@ -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<boolean>
|
||||
wordCounter: Accessor<WordCounter>
|
||||
form: ShoutForm
|
||||
|
@ -49,9 +50,11 @@ type EditorContextType = {
|
|||
countWords: (value: WordCounter) => void
|
||||
setForm: SetStoreFunction<ShoutForm>
|
||||
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() {
|
||||
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<Editor | undefined>()
|
||||
const { addFeed } = useFeed()
|
||||
const snackbar = useSnackbar()
|
||||
const [isEditorPanelVisible, setIsEditorPanelVisible] = createSignal<boolean>(false)
|
||||
|
@ -278,7 +281,9 @@ export const EditorProvider = (props: { children: JSX.Element }) => {
|
|||
toggleEditorPanel,
|
||||
countWords,
|
||||
setForm,
|
||||
setFormErrors
|
||||
setFormErrors,
|
||||
editor,
|
||||
setEditor
|
||||
}
|
||||
|
||||
const value: EditorContextType = {
|
||||
|
|
|
@ -15,7 +15,7 @@ import { processPrepositions } from '~/intl/prepositions'
|
|||
|
||||
i18nextInit()
|
||||
|
||||
type LocalizeContextType = {
|
||||
export type LocalizeContextType = {
|
||||
t: i18n['t']
|
||||
lang: Accessor<Language>
|
||||
setLang: (lang: Language) => void
|
||||
|
@ -26,7 +26,7 @@ type LocalizeContextType = {
|
|||
|
||||
export type Language = 'ru' | 'en'
|
||||
|
||||
const LocalizeContext = createContext<LocalizeContextType>({
|
||||
export const LocalizeContext = createContext<LocalizeContextType>({
|
||||
t: (s: string) => s
|
||||
} as LocalizeContextType)
|
||||
|
||||
|
|
|
@ -85,7 +85,8 @@ const metaRes = {
|
|||
}
|
||||
}
|
||||
}
|
||||
const SessionContext = createContext<SessionContextType>({} as SessionContextType)
|
||||
|
||||
export const SessionContext = createContext<SessionContextType>({} as SessionContextType)
|
||||
|
||||
export function useSession() {
|
||||
return useContext(SessionContext)
|
||||
|
|
|
@ -17,12 +17,12 @@ type SnackbarMessage = {
|
|||
duration?: number
|
||||
}
|
||||
|
||||
type SnackbarContextType = {
|
||||
export type SnackbarContextType = {
|
||||
snackbarMessage: Accessor<SnackbarMessage | null | undefined>
|
||||
showSnackbar: (message: SnackbarMessage) => Promise<void>
|
||||
}
|
||||
|
||||
const SnackbarContext = createContext<SnackbarContextType>({
|
||||
export const SnackbarContext = createContext<SnackbarContextType>({
|
||||
snackbarMessage: () => undefined,
|
||||
showSnackbar: async (_m: SnackbarMessage) => undefined
|
||||
} as SnackbarContextType)
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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 (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] = []
|
||||
|
||||
// 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
|
||||
|
|
|
@ -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, '')
|
||||
}
|
||||
|
|
|
@ -24,7 +24,7 @@ const polyfillOptions = {
|
|||
export default {
|
||||
resolve: {
|
||||
alias: {
|
||||
'~': path.resolve(__dirname, './src')
|
||||
'~': path.resolve('./src')
|
||||
}
|
||||
},
|
||||
envPrefix: 'PUBLIC_',
|
||||
|
|
Loading…
Reference in New Issue
Block a user