tippy-floating-fix

This commit is contained in:
Untone 2024-10-11 23:49:34 +03:00
parent 73f78823e0
commit 424af47b38
5 changed files with 231 additions and 148 deletions

View File

@ -140,7 +140,8 @@ export const EditorComponent = (props: Props) => {
}), }),
FloatingMenu.configure({ FloatingMenu.configure({
tippyOptions: { tippyOptions: {
placement: 'left' placement: 'left',
appendTo: document.body
}, },
element: floatingMenuRef()! element: floatingMenuRef()!
}), }),
@ -151,8 +152,8 @@ export const EditorComponent = (props: Props) => {
content: props.initialContent || null, content: props.initialContent || null,
onTransaction: ({ editor: e, transaction }) => { onTransaction: ({ editor: e, transaction }) => {
if (transaction.docChanged) { if (transaction.docChanged) {
const html = e.getHTML() //const html = e.getHTML()
html && props.onChange(html) //html && props.onChange(html)
const wordCount: number = e.storage.characterCount.words() const wordCount: number = e.storage.characterCount.words()
const charsCount: number = e.storage.characterCount.characters() const charsCount: number = e.storage.characterCount.characters()
charsCount && countWords({ words: wordCount, characters: charsCount }) charsCount && countWords({ words: wordCount, characters: charsCount })

View File

@ -1,8 +1,6 @@
import { clsx } from 'clsx' import { clsx } from 'clsx'
import deepEqual from 'fast-deep-equal' import deepEqual from 'fast-deep-equal'
import { Show, createEffect, createSignal, on, onCleanup, onMount } from 'solid-js' import { Show, createEffect, createSignal, on, onCleanup, onMount } from 'solid-js'
import { createStore } from 'solid-js/store'
import { debounce } from 'throttle-debounce'
import { EditorComponent } from '~/components/Editor/Editor' import { EditorComponent } from '~/components/Editor/Editor'
import { DropArea } from '~/components/_shared/DropArea' import { DropArea } from '~/components/_shared/DropArea'
import { Icon } from '~/components/_shared/Icon' import { Icon } from '~/components/_shared/Icon'
@ -20,17 +18,16 @@ import { getImageUrl } from '~/lib/getThumbUrl'
import { isDesktop } from '~/lib/mediaQuery' 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 { AutoSaveNotice } from '../Editor/AutoSaveNotice' import { AutoSaveNotice } from '../Editor/AutoSaveNotice'
import { MicroEditor } from '../Editor/MicroEditor'
import { Panel } from '../Editor/Panel/Panel' import { Panel } from '../Editor/Panel/Panel'
import { AudioUploader } from '../Upload/AudioUploader' import { AudioUploader } from '../Upload/AudioUploader'
import { VideoUploader } from '../Upload/VideoUploader' import { VideoUploader } from '../Upload/VideoUploader'
import { GrowingTextarea } from '../_shared/GrowingTextarea/GrowingTextarea'
import { Modal } from '../_shared/Modal' import { Modal } from '../_shared/Modal'
import { TableOfContents } from '../_shared/TableOfContents' import { TableOfContents } from '../_shared/TableOfContents'
import styles from '~/styles/views/EditView.module.scss' import styles from '~/styles/views/EditView.module.scss'
import MicroEditor from '../Editor/MicroEditor'
import GrowingTextarea from '../_shared/GrowingTextarea/GrowingTextarea'
type Props = { type Props = {
shout: Shout shout: Shout
@ -42,8 +39,6 @@ export const EMPTY_TOPIC: Topic = {
slug: '' slug: ''
} }
const AUTO_SAVE_DELAY = 3000
const handleScrollTopButtonClick = (ev: MouseEvent | TouchEvent) => { const handleScrollTopButtonClick = (ev: MouseEvent | TouchEvent) => {
ev.preventDefault() ev.preventDefault()
window?.scrollTo({ window?.scrollTo({
@ -55,19 +50,10 @@ const handleScrollTopButtonClick = (ev: MouseEvent | TouchEvent) => {
export const EditView = (props: Props) => { export const EditView = (props: Props) => {
const { t } = useLocalize() const { t } = useLocalize()
const { client } = useSession() const { client } = useSession()
const { const { form, formErrors, setForm, setFormErrors, handleInputChange, getDraftFromLocalStorage, saving } =
form, useEditorContext()
formErrors,
setForm,
setFormErrors,
saveDraft,
saveDraftToLocalStorage,
getDraftFromLocalStorage
} = useEditorContext()
const [subtitleInput, setSubtitleInput] = createSignal<HTMLTextAreaElement | undefined>() const [subtitleInput, setSubtitleInput] = createSignal<HTMLTextAreaElement | undefined>()
const [prevForm, setPrevForm] = createStore<ShoutForm>(clone(form))
const [saving, setSaving] = createSignal(false)
const [isSubtitleVisible, setIsSubtitleVisible] = createSignal(Boolean(form.subtitle)) const [isSubtitleVisible, setIsSubtitleVisible] = createSignal(Boolean(form.subtitle))
const [isLeadVisible, setIsLeadVisible] = createSignal(Boolean(form.lead)) const [isLeadVisible, setIsLeadVisible] = createSignal(Boolean(form.lead))
const [isScrolled, setIsScrolled] = createSignal(false) const [isScrolled, setIsScrolled] = createSignal(false)
@ -80,68 +66,46 @@ export const EditView = (props: Props) => {
createEffect( createEffect(
on( on(
() => props.shout, () => props.shout,
(shout) => { async (shout) => {
if (shout) { if (shout) {
// console.debug(`[EditView] shout is loaded: ${shout}`)
setShoutTopics((shout.topics as Topic[]) || []) setShoutTopics((shout.topics as Topic[]) || [])
const stored = getDraftFromLocalStorage(shout.id) const stored = getDraftFromLocalStorage(shout.id)
if (stored) { if (stored) {
// console.info(`[EditView] got stored shout: ${stored}`)
setDraft((old) => ({ ...old, ...stored }) as Shout) setDraft((old) => ({ ...old, ...stored }) as Shout)
setForm(stored as ShoutForm)
} else { } else {
if (!shout.slug) { if (!shout.slug) {
console.warn(`[EditView] shout has no slug! ${shout}`) console.warn(`[EditView] shout has no slug! ${shout}`)
} }
const resp = await client()?.query(getMyShoutQuery, { shout_id: shout.id })
const result = resp?.data?.get_my_shout
if (result) {
const { shout: loadedShout, error } = result
if (error) {
console.log(error)
} else {
setDraft(loadedShout)
const draftForm = { const draftForm = {
slug: shout.slug || '', slug: loadedShout.slug || '',
shoutId: shout.id || 0, shoutId: loadedShout.id || 0,
title: shout.title || '', title: loadedShout.title || '',
lead: shout.lead || '', lead: loadedShout.lead || '',
description: shout.description || '', description: loadedShout.description || '',
subtitle: shout.subtitle || '', subtitle: loadedShout.subtitle || '',
selectedTopics: (shoutTopics() || []) as Topic[], selectedTopics: (shoutTopics() || []) as Topic[],
mainTopic: shoutTopics()[0] || '', mainTopic: shoutTopics()[0] || '',
body: shout.body || '', body: loadedShout.body || '',
coverImageUrl: shout.cover || '', coverImageUrl: loadedShout.cover || '',
media: shout.media || '', media: loadedShout.media || '',
layout: shout.layout layout: loadedShout.layout
} }
setForm((_) => draftForm) setForm(draftForm)
console.debug('draft from props data: ', draftForm)
} }
} }
},
{ defer: true }
)
)
createEffect(
on(
draft,
(d) => {
if (d) {
const draftForm = Object.keys(d) ? d : { shoutId: props.shout.id }
setForm(draftForm as ShoutForm)
console.debug('draft from localstorage: ', draftForm)
}
},
{ defer: true }
)
)
createEffect(
on(
() => props.shout?.id,
async (shoutId) => {
if (shoutId) {
const resp = await client()?.query(getMyShoutQuery, { shout_id: shoutId })
const result = resp?.data?.get_my_shout
if (result) {
// console.debug('[EditView] getMyShout result: ', result)
const { shout: loadedShout, error } = result
setDraft(loadedShout)
// console.debug('[EditView] loadedShout:', loadedShout)
error && console.log(error)
} }
} }
}, },
@ -160,6 +124,7 @@ export const EditView = (props: Props) => {
}) })
const handleBeforeUnload = (event: BeforeUnloadEvent) => { const handleBeforeUnload = (event: BeforeUnloadEvent) => {
const prevForm = getDraftFromLocalStorage(form.shoutId) || {}
if (!deepEqual(prevForm, form)) { if (!deepEqual(prevForm, form)) {
event.returnValue = t( event.returnValue = t(
'There are unsaved changes in your publishing settings. Are you sure you want to leave the page without saving?' 'There are unsaved changes in your publishing settings. Are you sure you want to leave the page without saving?'
@ -226,34 +191,6 @@ export const EditView = (props: Props) => {
} }
} }
} }
const [hasChanges, setHasChanges] = createSignal(false)
const autoSave = async () => {
console.log('autoSave called')
if (hasChanges()) {
console.debug('saving draft', form)
setSaving(true)
saveDraftToLocalStorage(form)
await saveDraft(form)
setPrevForm(clone(form))
setSaving(false)
setHasChanges(false)
}
}
const debouncedAutoSave = debounce(AUTO_SAVE_DELAY, autoSave)
const handleInputChange = (key: keyof ShoutForm, value: string) => {
console.log(`[handleInputChange] ${key}: ${value}`)
setForm(key, value)
setHasChanges(true)
debouncedAutoSave()
}
onMount(() => {
onCleanup(() => {
debouncedAutoSave.cancel()
})
})
const showSubtitleInput = () => { const showSubtitleInput = () => {
setIsSubtitleVisible(true) setIsSubtitleVisible(true)

View File

@ -17,7 +17,7 @@ type Props = {
textAreaRef?: (el: HTMLTextAreaElement) => void textAreaRef?: (el: HTMLTextAreaElement) => void
} }
const GrowingTextarea = (props: Props) => { export const GrowingTextarea = (props: Props) => {
const [value, setValue] = createSignal<string>('') const [value, setValue] = createSignal<string>('')
const [isFocused, setIsFocused] = createSignal(false) const [isFocused, setIsFocused] = createSignal(false)

View File

@ -1,8 +1,9 @@
import { useMatch, useNavigate } from '@solidjs/router' import { useMatch, useNavigate } from '@solidjs/router'
import { Editor } from '@tiptap/core' import { Editor } from '@tiptap/core'
import type { JSX } from 'solid-js' import type { JSX } from 'solid-js'
import { Accessor, createContext, createSignal, useContext } from 'solid-js' import { Accessor, createContext, createSignal, onCleanup, useContext } from 'solid-js'
import { SetStoreFunction, createStore } from 'solid-js/store' import { SetStoreFunction, createStore } from 'solid-js/store'
import { debounce } from 'throttle-debounce'
import { useSnackbar } from '~/context/ui' import { useSnackbar } from '~/context/ui'
import deleteShoutQuery from '~/graphql/mutation/core/article-delete' import deleteShoutQuery from '~/graphql/mutation/core/article-delete'
import updateShoutQuery from '~/graphql/mutation/core/article-update' import updateShoutQuery from '~/graphql/mutation/core/article-update'
@ -12,6 +13,8 @@ import { useFeed } from '../context/feed'
import { useLocalize } from './localize' import { useLocalize } from './localize'
import { useSession } from './session' import { useSession } from './session'
export const AUTO_SAVE_DELAY = 3000
export type WordCounter = { export type WordCounter = {
characters: number characters: number
words: number words: number
@ -52,6 +55,9 @@ export type EditorContextType = {
setEditing: SetStoreFunction<Editor | undefined> setEditing: SetStoreFunction<Editor | undefined>
isCollabMode: Accessor<boolean> isCollabMode: Accessor<boolean>
setIsCollabMode: SetStoreFunction<boolean> setIsCollabMode: SetStoreFunction<boolean>
handleInputChange: (key: keyof ShoutForm, value: string) => void
saving: Accessor<boolean>
hasChanges: Accessor<boolean>
} }
export const EditorContext = createContext<EditorContextType>({} as EditorContextType) export const EditorContext = createContext<EditorContextType>({} as EditorContextType)
@ -79,6 +85,14 @@ const removeDraftFromLocalStorage = (shoutId: number) => {
localStorage?.removeItem(`shout-${shoutId}`) localStorage?.removeItem(`shout-${shoutId}`)
} }
const defaultForm: ShoutForm = {
body: '',
slug: '',
shoutId: 0,
title: '',
selectedTopics: []
}
export const EditorProvider = (props: { children: JSX.Element }) => { export const EditorProvider = (props: { children: JSX.Element }) => {
const localize = useLocalize() const localize = useLocalize()
const navigate = useNavigate() const navigate = useNavigate()
@ -88,20 +102,17 @@ export const EditorProvider = (props: { children: JSX.Element }) => {
const { addFeed } = useFeed() const { addFeed } = useFeed()
const snackbar = useSnackbar() const snackbar = useSnackbar()
const [isEditorPanelVisible, setIsEditorPanelVisible] = createSignal<boolean>(false) const [isEditorPanelVisible, setIsEditorPanelVisible] = createSignal<boolean>(false)
const [form, setForm] = createStore<ShoutForm>({ const [form, setForm] = createStore<ShoutForm>(defaultForm)
body: '',
slug: '',
shoutId: 0,
title: '',
selectedTopics: []
})
const [formErrors, setFormErrors] = createStore({} as Record<keyof ShoutForm, string>) const [formErrors, setFormErrors] = createStore({} as Record<keyof ShoutForm, string>)
const [wordCounter, setWordCounter] = createSignal<WordCounter>({ const [wordCounter, setWordCounter] = createSignal<WordCounter>({ characters: 0, words: 0 })
characters: 0,
words: 0
})
const toggleEditorPanel = () => setIsEditorPanelVisible((value) => !value) const toggleEditorPanel = () => setIsEditorPanelVisible((value) => !value)
const [isCollabMode, setIsCollabMode] = createSignal<boolean>(false) const [isCollabMode, setIsCollabMode] = createSignal<boolean>(false)
// current publishing editor instance to connect settings, panel and editor
const [editing, setEditing] = createSignal<Editor | undefined>(undefined)
const [saving, setSaving] = createSignal(false)
const [hasChanges, setHasChanges] = createSignal(false)
const countWords = (value: WordCounter) => setWordCounter(value) const countWords = (value: WordCounter) => setWordCounter(value)
const validate = () => { const validate = () => {
if (!form.title) { if (!form.title) {
@ -157,15 +168,9 @@ export const EditorProvider = (props: { children: JSX.Element }) => {
} }
const saveShout = async (formToSave: ShoutForm) => { const saveShout = async (formToSave: ShoutForm) => {
if (isEditorPanelVisible()) { isEditorPanelVisible() && toggleEditorPanel()
toggleEditorPanel()
}
if (matchEdit() && !validate()) { if ((matchEdit() && !validate()) || (matchEditSettings() && !validateSettings())) {
return
}
if (matchEditSettings() && !validateSettings()) {
return return
} }
@ -176,12 +181,7 @@ export const EditorProvider = (props: { children: JSX.Element }) => {
return return
} }
removeDraftFromLocalStorage(formToSave.shoutId) removeDraftFromLocalStorage(formToSave.shoutId)
navigate(shout?.published_at ? `/article/${shout.slug}` : '/edit')
if (shout?.published_at) {
navigate(`/article/${shout.slug}`)
} else {
navigate('/edit')
}
} catch (error) { } catch (error) {
console.error('[saveShout]', error) console.error('[saveShout]', error)
snackbar?.showSnackbar({ type: 'error', body: localize?.t('Error') || '' }) snackbar?.showSnackbar({ type: 'error', body: localize?.t('Error') || '' })
@ -197,25 +197,21 @@ export const EditorProvider = (props: { children: JSX.Element }) => {
} }
const publishShout = async (formToPublish: ShoutForm) => { const publishShout = async (formToPublish: ShoutForm) => {
if (isEditorPanelVisible()) { isEditorPanelVisible() && toggleEditorPanel()
toggleEditorPanel()
if ((matchEdit() && !validate()) || (matchEditSettings() && !validateSettings())) {
return
} }
if (matchEdit()) { if (matchEdit()) {
if (!validate()) return
const slug = slugify(form.title) const slug = slugify(form.title)
setForm('slug', slug) setForm('slug', slug)
navigate(`/edit/${form.shoutId}/settings`) navigate(`/edit/${form.shoutId}/settings`)
const { error } = await updateShout(formToPublish, { publish: false }) const { error } = await updateShout(formToPublish, { publish: false })
if (error) { if (error) {
snackbar?.showSnackbar({ type: 'error', body: localize?.t(error) || '' }) snackbar?.showSnackbar({ type: 'error', body: localize?.t(error) || '' })
}
return return
} }
if (!validateSettings()) {
return
} }
try { try {
@ -269,8 +265,25 @@ export const EditorProvider = (props: { children: JSX.Element }) => {
} }
} }
// current publishing editor instance to connect settings, panel and editor const debouncedAutoSave = debounce(AUTO_SAVE_DELAY, async () => {
const [editing, setEditing] = createSignal<Editor | undefined>(undefined) console.log('autoSave called')
if (hasChanges()) {
console.debug('saving draft', form)
setSaving(true)
saveDraftToLocalStorage(form)
await saveDraft(form)
setSaving(false)
setHasChanges(false)
}
})
onCleanup(debouncedAutoSave.cancel)
const handleInputChange = (key: keyof ShoutForm, value: string) => {
console.log(`[handleInputChange] ${key}: ${value}`)
setForm(key, value)
setHasChanges(true)
debouncedAutoSave()
}
const actions = { const actions = {
saveShout, saveShout,
@ -286,7 +299,10 @@ export const EditorProvider = (props: { children: JSX.Element }) => {
setFormErrors, setFormErrors,
setEditing, setEditing,
isCollabMode, isCollabMode,
setIsCollabMode setIsCollabMode,
handleInputChange,
saving,
hasChanges
} }
const value: EditorContextType = { const value: EditorContextType = {

View File

@ -198,6 +198,143 @@ button {
} }
} }
.button--subscribe {
background: var(--background-color);
color: var(--default-color);
border: 2px solid var(--black-100);
font-size: 1.5rem;
justify-content: center;
padding: 0.6rem 1.2rem;
transition: background-color 0.2s;
img {
height: auto;
transition: filter 0.2s;
}
&:hover {
background: var(--background-color-invert);
color: var(--default-color-invert);
img {
filter: invert(1);
}
}
}
.button--light {
font-size:1.5rem;
background-color: var(--black-100);
border-radius: 0.8rem;
color: var(--default-color);
font-weight: 500;
height: auto;
padding: 0.6rem 1.2rem 0.6rem 1rem;
&:hover {
background: var(--black-300);
}
}
.button--subscribe-topic {
background: var(--background-color);
color: var(--default-color);
border: 2px solid var(--default-color);
border-radius: 0.8rem;
font-size: 1.4rem;
line-height: 2.8rem;
height: 3.2rem;
padding: 0 1rem;
&:hover {
background: var(--background-color-invert);
color: var(--default-color-invert);
opacity: 1;
.icon {
filter: invert(1);
}
}
&[disabled]:hover {
background: var(--background-color);
color: var(--default-color);
}
.icon {
display: inline-block;
margin-right: 0.3em;
vertical-align: text-bottom;
width: 1.4em;
}
}
.button--content-index {
@include media-breakpoint-up(md) {
margin-top: -0.5rem;
position: sticky;
top: 135px;
}
@include media-breakpoint-up(sm) {
right: $container-padding-x;
}
background: none;
border: 2px solid var(--white-500);
height: 3.2rem;
float: right;
padding: 0;
position: absolute;
right: $container-padding-x * 0.5;
top: -0.5rem;
width: 3.2rem;
z-index: 1;
.icon {
background: #fff;
transition: filter 0.3s;
}
.icon,
img {
height: 100%;
vertical-align: middle;
width: auto;
}
&:hover {
.icon {
filter: invert(1);
}
}
.expanded {
border-radius: 100%;
overflow: hidden;
img {
height: auto;
margin-top: 0.8rem;
}
}
}
.button--submit,
.button--outline {
font-size:2rem;
padding: 1.6rem 2rem;
}
.button--outline {
background: none;
box-shadow: inset 0 0 0 2px #000;
color: #000;
&:hover {
box-shadow: inset 0 0 0 2px var(--black-300);
}
}
form { form {
input[type='text'], input[type='text'],
@ -819,11 +956,3 @@ iframe {
filter: invert(1); filter: invert(1);
} }
} }
.fixed {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 1030;
}