文件
mynode/packages/editor/ProseMirror/plugins/utils/createFloatingButtonPlugin.ts
chen 301194963b
一些检查失败了
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
feat: Initial clean release of CHM
2026-11-04 11:29:39 +08:00

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;