import React from 'react';
import { defineMessages, InjectedIntl } from 'react-intl';
import { EditorView } from 'prosemirror-view';
import { EditorState } from 'prosemirror-state';
import { NodeType } from 'prosemirror-model';
import CommentIcon from '@atlaskit/icon/glyph/comment';
// AFP-2532 TODO: Fix automatic suppressions below
// eslint-disable-next-line @atlassian/tangerine/import/entry-points
import { Position } from '@atlaskit/editor-common/src/ui/Popup/utils';
import { Command } from '../../types';
import { addInlineComment, ToolTipContent } from '../../keymaps';
import {
FloatingToolbarConfig,
FloatingToolbarButton,
} from '../../plugins/floating-toolbar/types';
import { setInlineCommentDraftState } from './commands';
import { AnnotationTestIds, AnnotationSelectionType } from './types';
import { isSelectionValid } from './utils';
export const annotationMessages = defineMessages({
createComment: {
id: 'fabric.editor.createComment',
defaultMessage: 'Comment',
description: 'Create/add an inline comment based on the users selection',
},
createCommentInvalid: {
id: 'fabric.editor.createCommentInvalid',
defaultMessage: 'You can only comment on text and headings',
description:
'Error message to communicate to the user they can only do the current action in certain contexts',
},
toolbar: {
id: 'fabric.editor.annotationToolbar',
defaultMessage: 'Annotation toolbar',
description:
'A label for a toolbar (UI element) that creates annotations/comments in the document',
},
});
/*
Calculates the position of the floating toolbar relative to the selection.
This is a re-implementation which closely matches the behaviour on Confluence renderer.
The main difference is the popup is always above the selection.
Things to consider:
- popup is always above the selection
- coordinates of head X and getBoundingClientRect() are absolute in client viewport (not including scroll offsets)
- popup may appear in '.fabric-editor-popup-scroll-parent' (or body)
- we use the toolbarRect to center align toolbar
- use wrapperBounds to clamp values
- editorView.dom bounds differ to wrapperBounds, convert at the end
*/
const calculateToolbarPositionAboveSelection = (toolbarTitle: string) => (
editorView: EditorView,
nextPos: Position,
): Position => {
const toolbar = document.querySelector(
`div[aria-label="${toolbarTitle}"]`,
) as HTMLElement;
if (!toolbar) {
return nextPos;
}
// scroll wrapper for full page, fall back to document body
// TODO: look into using getScrollGutterOptions()
const scrollWrapper =
editorView.dom.closest('.fabric-editor-popup-scroll-parent') ||
document.body;
const wrapperBounds = scrollWrapper.getBoundingClientRect();
const selection = window && window.getSelection();
const range = selection && !selection.isCollapsed && selection.getRangeAt(0);
if (!range) {
return nextPos;
}
const toolbarRect = toolbar.getBoundingClientRect();
const { head, anchor } = editorView.state.selection;
const topCoords = editorView.coordsAtPos(Math.min(head, anchor));
const bottomCoords = editorView.coordsAtPos(
Math.max(head, anchor) - Math.min(range.endOffset, 1),
);
const top = (topCoords.top || 0) - toolbarRect.height * 1.5;
let left = 0;
// If not on the same line
if (topCoords.top !== bottomCoords.top) {
// selecting downwards
if (head > anchor) {
left = Math.max(topCoords.right, bottomCoords.right);
} else {
left = Math.min(topCoords.left, bottomCoords.left);
}
/*
short selection above a long paragraph
eg. short {<}heading
The purpose of this text is to show the selection range{>}.
The horizontal positioning should center around "heading",
not where it ends at "range".
Note: if it was "heading" then it would only center
around "head". Undesireable but matches the current renderer.
*/
const cliffPosition = range.getClientRects()[0];
if (cliffPosition.right < left) {
left = cliffPosition.left + cliffPosition.width / 2;
}
} else {
// Otherwise center on the single line selection
left = topCoords.left + (bottomCoords.right - topCoords.left) / 2;
}
left -= toolbarRect.width / 2;
// remap positions from browser document to wrapperBounds
return {
top: top - wrapperBounds.top + scrollWrapper.scrollTop,
left: Math.max(0, left - wrapperBounds.left),
};
};
/*
Calculates the position of the floating toolbar relative to the selection.
This is a re-implementation which closely matches the behaviour on Confluence renderer.
The main difference is the popup is always above the selection.
Things to consider:
- stick as close to the head X release coordinates as possible
- coordinates of head X and getBoundingClientRect() are absolute in client viewport (not including scroll offsets)
- popup may appear in '.fabric-editor-popup-scroll-parent' (or body)
- we use the toolbarRect to center align toolbar
- use wrapperBounds to clamp values
- editorView.dom bounds differ to wrapperBounds, convert at the end
*/
const calculateToolbarPositionTrackHead = (toolbarTitle: string) => (
editorView: EditorView,
nextPos: Position,
): Position => {
const toolbar = document.querySelector(
`div[aria-label="${toolbarTitle}"]`,
) as HTMLElement;
if (!toolbar) {
return nextPos;
}
// scroll wrapper for full page, fall back to document body
// TODO: look into using getScrollGutterOptions()
const scrollWrapper =
editorView.dom.closest('.fabric-editor-popup-scroll-parent') ||
document.body;
const wrapperBounds = scrollWrapper.getBoundingClientRect();
const selection = window && window.getSelection();
const range = selection && !selection.isCollapsed && selection.getRangeAt(0);
if (!range) {
return nextPos;
}
const toolbarRect = toolbar.getBoundingClientRect();
const { head, anchor } = editorView.state.selection;
let topCoords = editorView.coordsAtPos(Math.min(head, anchor));
let bottomCoords = editorView.coordsAtPos(
Math.max(head, anchor) - Math.min(range.endOffset, 1),
);
let top;
// If not the same line, display toolbar below.
if (head > anchor && topCoords.top !== bottomCoords.top) {
// We are taking the previous pos to the maxium, so avoid end of line positions
// returning the next line's rect.
top = (bottomCoords.top || 0) + toolbarRect.height / 1.15;
} else {
top = (topCoords.top || 0) - toolbarRect.height * 1.5;
}
const left =
(head > anchor ? bottomCoords.right : topCoords.left) -
toolbarRect.width / 2;
// remap positions from browser document to wrapperBounds
return {
top: top - wrapperBounds.top + scrollWrapper.scrollTop,
left: Math.max(0, left - wrapperBounds.left),
};
};
export const buildToolbar = (
state: EditorState,
intl: InjectedIntl,
isToolbarAbove: boolean = false,
): FloatingToolbarConfig | undefined => {
const { schema } = state;
const selectionValid = isSelectionValid(state);
if (selectionValid === AnnotationSelectionType.INVALID) {
return undefined;
}
const createCommentMessage = intl.formatMessage(
annotationMessages.createComment,
);
const commentDisabledMessage = intl.formatMessage(
annotationMessages.createCommentInvalid,
);
const createComment: FloatingToolbarButton = {
type: 'button',
showTitle: true,
disabled: selectionValid === AnnotationSelectionType.DISABLED,
testId: AnnotationTestIds.floatingToolbarCreateButton,
icon: CommentIcon,
tooltipContent:
selectionValid === AnnotationSelectionType.DISABLED ? (
commentDisabledMessage
) : (
),
title: createCommentMessage,
onClick: (state, dispatch) => {
return setInlineCommentDraftState(true)(state, dispatch);
},
};
const { annotation } = schema.marks;
const validNodes = Object.keys(schema.nodes).reduce(
(acc, current) => {
const type = schema.nodes[current];
if (type.allowsMarkType(annotation)) {
acc.push(type);
}
return acc;
},
[],
);
const toolbarTitle = intl.formatMessage(annotationMessages.toolbar);
const calcToolbarPosition = isToolbarAbove
? calculateToolbarPositionAboveSelection
: calculateToolbarPositionTrackHead;
const onPositionCalculated = calcToolbarPosition(toolbarTitle);
return {
title: toolbarTitle,
nodeType: validNodes,
items: [createComment],
onPositionCalculated,
};
};