一些检查失败了
automerge / automerge (push) Has been cancelled
react-native-android-build-apk / AssembleRelease (push) Has been cancelled
Build macOS M1 / Main (push) Has been cancelled
Joplin Continuous Integration / Main (macos-15-intel) (push) Has been cancelled
Joplin Continuous Integration / Main (ubuntu-22.04) (push) Has been cancelled
Joplin Continuous Integration / Main (ubuntu-22.04-arm) (push) Has been cancelled
Joplin Continuous Integration / Main (windows-11-vs2026-arm) (push) Has been cancelled
Joplin Continuous Integration / Main (windows-2025) (push) Has been cancelled
Joplin Continuous Integration / ServerDockerImage (ubuntu-22.04) (push) Has been cancelled
Joplin Continuous Integration / ServerDockerImage (ubuntu-22.04-arm) (push) Has been cancelled
Joplin UI tests / Main (ubuntu-22.04) (push) Has been cancelled
Joplin UI tests / Main (windows-2025) (push) Has been cancelled
Joplin UI tests / Mobile (push) Has been cancelled
Close stale issues / ProcessStaleIssues (push) Has been cancelled
297 行
9.4 KiB
TypeScript
297 行
9.4 KiB
TypeScript
import { Command, EditorState, Plugin, PluginView } from 'prosemirror-state';
|
|
import { LocalizationResult, OnLocalize } from '../../../types';
|
|
import { EditorView } from 'prosemirror-view';
|
|
import createButton from '../../utils/dom/createButton';
|
|
import { getEditorApi } from '../joplinEditorApiPlugin';
|
|
import { Node } from 'prosemirror-model';
|
|
import { Icon } from '../../vendor/icons/types';
|
|
|
|
type LocalizeFunction = (_: OnLocalize)=> LocalizationResult;
|
|
|
|
interface ButtonSpec {
|
|
icon?: Icon;
|
|
label: LocalizeFunction;
|
|
command: (node: Node, offset: number)=> Command;
|
|
showForNode?: (node: Node)=> boolean;
|
|
className?: string;
|
|
}
|
|
|
|
export enum ToolbarType {
|
|
// Attempts to keep the toolbar visible when the node
|
|
// is visible. While showing the toolbar outside the node
|
|
// is preferred, the toolbar will be shown inside the node
|
|
// if insufficient outside space is available.
|
|
FloatAboveBelow,
|
|
// Anchors the toolbar to the top right corner of the
|
|
// associated element.
|
|
AnchorTopRight,
|
|
}
|
|
|
|
interface TargetNode {
|
|
offset: number;
|
|
node: Node;
|
|
element: Element|null;
|
|
}
|
|
|
|
class FloatingButtonBar implements PluginView {
|
|
private container_: HTMLElement;
|
|
private buttonRow_: ButtonRow;
|
|
|
|
private currentTarget_: TargetNode|null = null;
|
|
private observer_: ElementObserver;
|
|
private editable_: boolean|null = null;
|
|
|
|
public constructor(
|
|
private view_: EditorView,
|
|
private targetNodeName_: string,
|
|
buttons: ButtonSpec[],
|
|
private type_: ToolbarType,
|
|
) {
|
|
this.container_ = document.createElement('div');
|
|
this.container_.classList.add('floating-button-bar');
|
|
|
|
this.buttonRow_ = new ButtonRow(this.container_, buttons);
|
|
|
|
this.observer_ = new ElementObserver(
|
|
() => this.repositionOverlay_(),
|
|
);
|
|
|
|
// Prevent other elements (e.g. checkboxes, links) from being between the toolbar button and the
|
|
// target element. If the toolbar is instead included **after** the Rich Text Editor's main content,
|
|
// then all items included directly within the Rich Text Editor come before the toolbar in the focus
|
|
// order.
|
|
view_.dom.parentElement.prepend(this.container_);
|
|
this.update(view_, null);
|
|
|
|
if (this.type_ === ToolbarType.AnchorTopRight) {
|
|
this.container_.classList.add('-anchored');
|
|
} else if (this.type_ === ToolbarType.FloatAboveBelow) {
|
|
this.container_.classList.add('-floating');
|
|
} else {
|
|
const unreachable_: never = this.type_;
|
|
throw new Error(`Unknown toolbar type: ${unreachable_}`);
|
|
}
|
|
}
|
|
|
|
private repositionOverlay_() {
|
|
if (!this.currentTarget_) return;
|
|
|
|
const overlay = this.container_;
|
|
const view = this.view_;
|
|
const target = this.currentTarget_;
|
|
const position = this.view_.coordsAtPos(target.offset);
|
|
const targetElement = view.nodeDOM(target.offset);
|
|
|
|
// Fall back to document.body to support testing environments:
|
|
const parentBox = (this.container_.offsetParent ?? document.body).getBoundingClientRect();
|
|
const tooltipBox = this.container_.getBoundingClientRect();
|
|
const targetBox = targetElement instanceof HTMLElement ? targetElement.getBoundingClientRect() : {
|
|
...position,
|
|
width: 0,
|
|
height: 0,
|
|
};
|
|
|
|
this.container_.style.left = '';
|
|
this.container_.style.right = '';
|
|
|
|
if (this.type_ === ToolbarType.FloatAboveBelow) {
|
|
const padding = 10;
|
|
const above = targetBox.top - tooltipBox.height - parentBox.top - padding;
|
|
const below = targetBox.top + targetBox.height - parentBox.top + padding;
|
|
const viewportTop = window.visualViewport?.pageTop;
|
|
const viewportBottom = viewportTop + window.visualViewport?.height;
|
|
const cursorTop = viewportTop + view.coordsAtPos(view.state.selection.head).top;
|
|
|
|
const getOffsetTop = () => {
|
|
// If the toolbar must be displayed within the element to be visible, prefer
|
|
// less movement:
|
|
const previousTop = tooltipBox.top + viewportTop;
|
|
const insideCandidates = [
|
|
Math.max(viewportTop + padding, above),
|
|
Math.min(viewportBottom - padding - tooltipBox.height, below),
|
|
].sort((a, b) => {
|
|
const distanceA = Math.abs(a - previousTop);
|
|
const distanceB = Math.abs(b - previousTop);
|
|
return distanceA - distanceB;
|
|
}).filter(position => {
|
|
return position >= above && position <= below;
|
|
});
|
|
|
|
const positionCandidates = [
|
|
// Always prefer showing the toolbar outside the element
|
|
above, below,
|
|
// Fall back to showing the toolbar inside
|
|
...insideCandidates,
|
|
];
|
|
|
|
const validCandidates = positionCandidates.filter((position) => {
|
|
const candidateTop = position;
|
|
const candidateBottom = position + tooltipBox.height;
|
|
const candidateCenter = position + tooltipBox.height / 2;
|
|
const distanceFromCursor = Math.abs(candidateCenter - cursorTop);
|
|
|
|
return candidateTop >= viewportTop
|
|
// Avoid showing the toolbar off the bottom edge of the screen
|
|
&& candidateBottom <= viewportBottom
|
|
// Avoid showing the toolbar on the same line as the cursor
|
|
&& distanceFromCursor > tooltipBox.height / 2 + padding;
|
|
});
|
|
return validCandidates[0] ?? positionCandidates[0];
|
|
};
|
|
|
|
const targetCenter = targetBox.left + targetBox.width / 2;
|
|
const currentCenter = parentBox.left + tooltipBox.width / 2;
|
|
// Subtract (parentBox.left, parentBox.top): style.left and style.top
|
|
// are relative to the parent, but the computed position is not.
|
|
overlay.style.left = `${Math.max(targetCenter - currentCenter, 0)}px`;
|
|
overlay.style.top = `${getOffsetTop()}px`;
|
|
} else if (this.type_ === ToolbarType.AnchorTopRight) {
|
|
overlay.style.right = `${parentBox.width - targetBox.width - (targetBox.left - parentBox.left)}px`;
|
|
overlay.style.top = `${targetBox.top - parentBox.top}px`;
|
|
}
|
|
}
|
|
|
|
public update(view: EditorView, lastState: EditorState|null) {
|
|
this.view_ = view;
|
|
const editableChanged = this.editable_ !== view.editable;
|
|
this.editable_ = view.editable;
|
|
if (!view.editable) {
|
|
this.observer_.setElement(null);
|
|
this.currentTarget_ = null;
|
|
this.container_.classList.add('-hidden');
|
|
return;
|
|
}
|
|
|
|
const state = view.state;
|
|
const sameSelection = lastState && state.selection.eq(lastState.selection);
|
|
const sameDoc = lastState && state.doc.eq(lastState.doc);
|
|
if (!editableChanged && sameSelection && sameDoc) {
|
|
return;
|
|
}
|
|
|
|
const findTargetNode = () => {
|
|
let target: TargetNode|null = null;
|
|
state.doc.nodesBetween(state.selection.from, state.selection.to, (node, offset) => {
|
|
if (node.type.name === this.targetNodeName_) {
|
|
const dom = view.nodeDOM(offset);
|
|
const domElement = dom instanceof HTMLElement ? dom : dom.parentElement;
|
|
target = { node, offset, element: domElement };
|
|
return false;
|
|
}
|
|
return true;
|
|
});
|
|
|
|
return target;
|
|
};
|
|
|
|
const target = findTargetNode();
|
|
this.observer_.setElement(target?.element);
|
|
this.currentTarget_ = target;
|
|
|
|
if (!target) {
|
|
this.container_.classList.add('-hidden');
|
|
} else {
|
|
this.container_.classList.remove('-hidden');
|
|
|
|
this.buttonRow_.updateButtons(view, target);
|
|
this.repositionOverlay_();
|
|
}
|
|
}
|
|
|
|
public destroy() {
|
|
this.observer_.destroy();
|
|
}
|
|
}
|
|
|
|
// Emits changes when the element's position changes.
|
|
class ElementObserver {
|
|
private intersectionObserver_: IntersectionObserver|null;
|
|
private lastElement_: Element|null = null;
|
|
|
|
public constructor(private onNodeUpdate_: ()=> void) {
|
|
if (typeof IntersectionObserver !== 'undefined') {
|
|
this.intersectionObserver_ = new IntersectionObserver(() => {
|
|
this.onNodeUpdate_();
|
|
});
|
|
}
|
|
document.addEventListener('scroll', this.onNodeUpdate_);
|
|
window.addEventListener('resize', this.onNodeUpdate_);
|
|
}
|
|
|
|
public setElement(element: Element|null) {
|
|
if (element === this.lastElement_) return;
|
|
|
|
if (this.lastElement_) {
|
|
this.intersectionObserver_?.unobserve(this.lastElement_);
|
|
}
|
|
|
|
if (element) {
|
|
this.intersectionObserver_?.observe(element);
|
|
}
|
|
|
|
this.lastElement_ = element;
|
|
}
|
|
|
|
public destroy() {
|
|
this.intersectionObserver_?.disconnect();
|
|
this.intersectionObserver_ = null;
|
|
|
|
document.removeEventListener('scroll', this.onNodeUpdate_);
|
|
window.removeEventListener('resize', this.onNodeUpdate_);
|
|
}
|
|
}
|
|
|
|
class ButtonRow {
|
|
private created_ = false;
|
|
public constructor(private container_: HTMLElement, private buttons_: ButtonSpec[]) { }
|
|
|
|
public updateButtons(view: EditorView, targetNode: TargetNode) {
|
|
// Late-init the buttons to allow accessing `view`:
|
|
if (!this.created_) {
|
|
this.created_ = true;
|
|
|
|
const { localize } = getEditorApi(view.state);
|
|
this.container_.replaceChildren(...this.buttons_.map(buttonSpec => {
|
|
const label = buttonSpec.label(localize);
|
|
const button = createButton(
|
|
buttonSpec.icon ? { label, icon: buttonSpec.icon() } : label,
|
|
() => { },
|
|
);
|
|
|
|
button.classList.add('action', 'action-button');
|
|
if (buttonSpec.icon) {
|
|
button.classList.add('-icon');
|
|
}
|
|
|
|
if (buttonSpec.className) {
|
|
button.classList.add(buttonSpec.className);
|
|
}
|
|
|
|
return button;
|
|
}));
|
|
}
|
|
|
|
// Update the button listeners and states based on the current view and
|
|
// target node
|
|
for (let i = 0; i < this.buttons_.length; i++) {
|
|
const button = this.container_.children[i] as HTMLButtonElement;
|
|
const buttonSpec = this.buttons_[i];
|
|
|
|
const command = buttonSpec.command(targetNode.node, targetNode.offset);
|
|
button.onclick = () => {
|
|
command(view.state, view.dispatch, view);
|
|
};
|
|
|
|
button.disabled = !command(view.state);
|
|
}
|
|
}
|
|
}
|
|
|
|
const createFloatingButtonPlugin = (nodeName: string, actions: ButtonSpec[], position: ToolbarType) => {
|
|
return new Plugin({
|
|
view: (view) => new FloatingButtonBar(view, nodeName, actions, position),
|
|
});
|
|
};
|
|
|
|
export default createFloatingButtonPlugin;
|