0
Fork 0
mirror of https://github.com/TryGhost/Ghost.git synced 2025-01-20 22:42:53 -05:00

Updated form to only close when another form opens and scroll better

fixes https://github.com/TryGhost/Team/issues/1753
refs https://github.com/TryGhost/Team/issues/1758

- Scroll only if form is not in viewport + scroll to center
- Fixes: You can’t tap reply if another reply box is open (or at least you have to tap a bunch of times)
This commit is contained in:
Simon Backx 2022-08-11 14:46:14 +02:00
parent 4a0ad8c6bc
commit ceaecf43e6
8 changed files with 131 additions and 26 deletions

View file

@ -27,6 +27,9 @@ const Comment = ({updateIsEditing = null, isEditing, ...props}) => {
let comment = props.comment;
useEffect(() => {
// This doesn't work, and should receive an update.
// When one Comment shows reply, while a different Form hides the reply form at the same time, the global
// 'isEditing' is unreliable. We should use a counter of total open forms instead of a boolean.
updateIsEditing?.(isInReplyMode || isInEditMode);
}, [updateIsEditing, isInReplyMode, isInEditMode]);
const toggleEditMode = () => {
@ -99,7 +102,7 @@ const Comment = ({updateIsEditing = null, isEditing, ...props}) => {
<div className="flex flex-row items-center gap-4 pb-[8px] pr-4 h-12">
<p className="font-sans text-[16px] leading-normal text-neutral-300 italic mt-[4px]">{notPublishedMessage}</p>
<div className="mt-[4px]">
<More comment={comment} toggleEdit={toggleEditMode} disableEditing={isEditing} />
<More comment={comment} toggleEdit={toggleEditMode} />
</div>
</div> :
<div>
@ -122,8 +125,8 @@ const Comment = ({updateIsEditing = null, isEditing, ...props}) => {
{!isNotPublished && (
<div className="flex gap-5 items-center">
{!isNotPublished && <Like comment={comment} />}
{!isNotPublished && (canReply && (isNotPublished || !props.parent) && <Reply disabled={!!isEditing} comment={comment} toggleReply={toggleReplyMode} isReplying={isInReplyMode} />)}
{!isNotPublished && <More comment={comment} toggleEdit={toggleEditMode} disableEditing={isEditing} />}
{!isNotPublished && (canReply && (isNotPublished || !props.parent) && <Reply comment={comment} toggleReply={toggleReplyMode} isReplying={isInReplyMode} />)}
{!isNotPublished && <More comment={comment} toggleEdit={toggleEditMode} />}
</div>
)}

View file

@ -50,6 +50,7 @@ const CommentsBoxTitle = ({title, showCount, count}) => {
};
const CommentsBoxContent = (props) => {
// @todo: This doesn't work and should get replaced with a counter of total open forms. If total open forms > 0, don't show the main form.
const [isEditing, setIsEditing] = useState(false);
const {pagination, member, comments, commentCount, commentsEnabled, title, showCount} = useContext(AppContext);

View file

@ -8,6 +8,9 @@ import {isMobile} from '../utils/helpers';
// import {formatRelativeTime} from '../utils/helpers';
import {ReactComponent as SpinnerIcon} from '../images/icons/spinner.svg';
import {ReactComponent as EditIcon} from '../images/icons/edit.svg';
import {GlobalEventBus} from '../utils/event-bus';
let formId = 0;
const Form = (props) => {
const {member, postId, dispatchAction, avatarSaturation} = useContext(AppContext);
@ -55,7 +58,7 @@ const Form = (props) => {
});
const getScrollToPosition = () => {
let yOffset = -100;
let yOffset = 0;
const element = formEl.current;
// Because we are working in an iframe, we need to resolve the position inside this iframe to the position in the top window
@ -83,7 +86,45 @@ const Form = (props) => {
return y;
};
// Generate an unique ID so we can exclude events that we send ourselve
const [uniqueId] = useState(() => {
formId += 1;
return 'form-' + formId;
});
const onOtherFormFocus = useCallback(() => {
// A different form got focus. Should we close this form now?
if ((props.isReply && editor?.isEmpty) || (props.isEdit && editor?.getHTML() === props.comment.html)) {
if (props.close) {
props.close();
}
}
}, [editor, props]);
useEffect(() => {
// Send event before attaching the listeer
GlobalEventBus.addListener(uniqueId, 'form-focus', onOtherFormFocus);
return () => {
GlobalEventBus.removeListener(uniqueId);
};
}, [onOtherFormFocus, uniqueId]);
useEffect(() => {
// When opening a reply or edit form, try to close other forms that are open and can get closed
if (props.isReply || props.isEdit) {
// Send a form-focus event, but exclude ourself (uniqueId)
GlobalEventBus.sendEvent('form-focus', {}, uniqueId);
}
}, [props.isReply, props.isEdit, uniqueId]);
const onFormFocus = useCallback(() => {
// When focusing the main form, try to close other forms that are open and can get closed
if (!props.isReply && !props.isEdit) {
// Send a form-focus event, but exclude ourself (uniqueId)
GlobalEventBus.sendEvent('form-focus', {}, uniqueId);
}
// Send an event around and try to close other forms that are open and can get closed
if (!memberName && !props.isEdit) {
setPreventClosing(true);
editor.commands.blur();
@ -103,7 +144,7 @@ const Form = (props) => {
} else {
setFormOpen(true);
}
}, [editor, dispatchAction, memberName, props]);
}, [editor, dispatchAction, memberName, props, uniqueId]);
// Set the cursor position at the end of the form, instead of the beginning (= when using autofocus)
useEffect(() => {
@ -116,12 +157,26 @@ const Form = (props) => {
// Scroll to view if it's a reply
if (props.isReply) {
timer = setTimeout(() => {
window.scrollTo({
top: getScrollToPosition(),
left: 0,
behavior: 'smooth'
});
}, 100);
// Is the form already in view?
const formHeight = 100;
const yMin = getScrollToPosition();
const yMax = yMin + formHeight;
const viewportHeight = window.innerHeight;
const viewPortYMin = window.scrollY;
const viewPortYMax = viewPortYMin + viewportHeight;
if (yMin < viewPortYMin || yMax > viewPortYMax) {
// Center the form in the viewport
const yCenter = (yMin + yMax) / 2;
window.scrollTo({
top: yCenter - viewportHeight / 2,
left: 0,
behavior: 'smooth'
});
}
}, 50);
}
// Focus editor + jump to end
@ -150,7 +205,7 @@ const Form = (props) => {
clearTimeout(timer);
}
};
}, [editor, props]);
}, [editor, props.isReply, props.isEdit]);
const submitForm = useCallback(async () => {
if (editor.isEmpty) {
@ -226,11 +281,6 @@ const Form = (props) => {
editor.on('blur', () => {
if (editor?.isEmpty) {
setFormOpen(false);
if (props.isReply && props.close && !preventClosing) {
// TODO: we cannot toggle the form when this happens, because when the member doesn't have a name we'll always loose focus to input the name...
// Need to find a different way for this behaviour
props.close();
}
}
});

View file

@ -30,7 +30,7 @@ const More = (props) => {
return (
<div className="relative">
{show ? <button onClick={toggleContextMenu} className="outline-0"><MoreIcon className='transition duration-50 ease-linear gh-comments-icon gh-comments-icon-more outline-0 fill-neutral-400 dark:fill-rgba(255,255,255,0.5) hover:fill-neutral-600' /></button> : null}
{isContextMenuOpen ? <CommentContextMenu comment={comment} close={closeContextMenu} toggleEdit={props.toggleEdit} disableEditing={props.disableEditing} /> : null}
{isContextMenuOpen ? <CommentContextMenu comment={comment} close={closeContextMenu} toggleEdit={props.toggleEdit} /> : null}
</div>
);
};

View file

@ -5,13 +5,8 @@ import {ReactComponent as ReplyIcon} from '../images/icons/reply.svg';
function Reply(props) {
const {member} = useContext(AppContext);
const preventDefault = (event) => {
// We need to prevent blurring the input field when clicking the reply button (that could cause blur + focus again because mousedown is causing the input blur, then onclick focusses again)
event.preventDefault();
};
return member ?
(<button disabled={!!props.disabled} className={`group transition-all duration-50 ease-linear flex font-sans items-center text-sm outline-0 ${props.isReplying ? 'text-neutral-900 dark:text-[rgba(255,255,255,0.9)]' : 'text-neutral-400 dark:text-[rgba(255,255,255,0.5)] hover:text-neutral-600'}`} onMouseDown={preventDefault} onClick={props.toggleReply}>
(<button disabled={!!props.disabled} className={`group transition-all duration-50 ease-linear flex font-sans items-center text-sm outline-0 ${props.isReplying ? 'text-neutral-900 dark:text-[rgba(255,255,255,0.9)]' : 'text-neutral-400 dark:text-[rgba(255,255,255,0.5)] hover:text-neutral-600'}`} onClick={props.toggleReply}>
<ReplyIcon className={`mr-[6px] ${props.isReplying ? 'fill-neutral-900 stroke-neutral-900 dark:fill-white dark:stroke-white' : 'stroke-neutral-400 dark:stroke-[rgba(255,255,255,0.5)] group-hover:stroke-neutral-600'} transition duration-50 ease-linear`} />Reply
</button>) : null;
}

View file

@ -11,7 +11,7 @@ const AuthorContextMenu = (props) => {
return (
<div className="flex flex-col">
<button className="w-full mb-3 text-left text-[14px]" onClick={props.toggleEdit} disabled={props.disableEditing}>
<button className="w-full mb-3 text-left text-[14px]" onClick={props.toggleEdit}>
Edit
</button>
<button className="w-full text-left text-[14px] text-red-600" onClick={deleteComment}>

View file

@ -55,7 +55,7 @@ const CommentContextMenu = (props) => {
let contextMenu = null;
if (comment.status === 'published') {
if (isAuthor) {
contextMenu = <AuthorContextMenu comment={comment} close={props.close} toggleEdit={props.toggleEdit} disableEditing={props.disableEditing} />;
contextMenu = <AuthorContextMenu comment={comment} close={props.close} toggleEdit={props.toggleEdit} />;
} else {
if (isAdmin) {
contextMenu = <AdminContextMenu comment={comment} close={props.close}/>;

View file

@ -0,0 +1,56 @@
class EventBus {
listeners = new Map()
addListener(owner, type, listener) {
const existing = this.listeners.get(owner);
if (existing) {
existing.push({type, listener});
} else {
this.listeners.set(owner, [{type, listener}]);
}
}
/**
*
* @param {*} owner
* @param {*} [type] Leave out if you want to remove all types
*/
removeListener(owner, type) {
if (type) {
const existing = this.listeners.get(owner);
if (existing) {
this.listeners.set(
owner,
existing.filter(t => t.type !== type)
);
}
} else {
this.listeners.delete(owner);
}
}
/**
*
* @param {*} type
* @param {*} value
* @param {*} [excludeOwner] Don't send the event to this owner (e.g. the sender of the message doesn't want to receive it when it is also listening for it)
* @returns
*/
sendEvent(type, value, excludeOwner) {
const values = [];
for (const owner of this.listeners.values()) {
if (excludeOwner !== undefined && owner === excludeOwner) {
return;
}
for (const listener of owner) {
if (listener.type === type) {
values.push(listener.listener(value, type));
}
}
}
return values;
}
}
export const GlobalEventBus = new EventBus();