Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,7 @@
"prosemirror-keymap": "^1.2.2",
"prosemirror-model": "^1.24.1",
"prosemirror-schema-list": "^1.5.0",
"prosemirror-search": "^1.1.0",
"prosemirror-state": "^1.4.3",
"prosemirror-test-builder": "^1.1.1",
"prosemirror-transform": "^1.10.2",
Expand Down
2 changes: 1 addition & 1 deletion src/bundle/MarkdownEditorView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -420,7 +420,7 @@ MarkdownEditorView.displayName = 'MarkdownEditorView';
interface MarkupSearchAnchorProps extends Pick<EditorSettingsProps, 'mode'> {}

const MarkupSearchAnchor: React.FC<MarkupSearchAnchorProps> = ({mode}) => (
<>{mode === 'markup' && <div className="g-md-search-anchor"></div>}</>
<div className={`g-md-search-${mode}-anchor`}></div>
);

function Settings(props: EditorSettingsProps & {stickyToolbar: boolean}) {
Expand Down
5 changes: 5 additions & 0 deletions src/bundle/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,11 @@ export type MarkdownEditorWysiwygConfig = {
* Note: The use of the markdown-it-attrs plugin will be removed in the next major version.
*/
disableMarkdownAttrs?: boolean;
/**
* Show search panel in the editor.
* @default true
*/
searchPanel?: boolean;
};

export type MarkdownEditorOptions = {
Expand Down
1 change: 1 addition & 0 deletions src/bundle/useMarkdownEditor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ export function useMarkdownEditor(
experimental.needToSetDimensionsForUploadedImages,
enableNewImageSizeCalculation: experimental.enableNewImageSizeCalculation,
mobile,
searchPanel: wysiwygConfig.searchPanel ?? true,
});
{
const extraExtensions = wysiwygConfig.extensions;
Expand Down
2 changes: 2 additions & 0 deletions src/bundle/wysiwyg-preset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export type BundlePresetOptions = ExtensionsOptions &
// MAJOR: remove markdown-it-attrs
disableMdAttrs?: boolean;
mobile?: boolean;
searchPanel: boolean;
};

declare global {
Expand Down Expand Up @@ -73,6 +74,7 @@ export const BundlePreset: ExtensionAuto<BundlePresetOptions> = (builder, opts)
},
},
cursor: {dropOptions: dropCursor},
search: opts.searchPanel ? {anchorSelector: '.g-md-search-wysiwyg-anchor'} : undefined,
clipboard: {pasteFileHandler: opts.fileUploadHandler, ...opts.clipboard},
selectionContext: {config: wSelectionMenuConfigByPreset.zero, ...opts.selectionContext},
commandMenu: {actions: wCommandMenuConfigByPreset.zero, ...opts.commandMenu},
Expand Down
11 changes: 11 additions & 0 deletions src/extensions/behavior/Search/Search.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import {search} from 'prosemirror-search';

import type {ExtensionAuto} from '#core';

import {type SearchViewPluginParams, searchViewPlugin} from './SearchViewPlugin';

export type SearchOptions = Pick<SearchViewPluginParams, 'anchorSelector'>;

export const Search: ExtensionAuto<SearchOptions> = (builder, opts) => {
builder.addPlugin(() => [search(), searchViewPlugin(opts)]);
};
187 changes: 187 additions & 0 deletions src/extensions/behavior/Search/SearchViewPlugin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
import {SearchQuery, getSearchState, setSearchState} from 'prosemirror-search';

import {type Command, Plugin, type PluginView} from '#pm/state';
import type {EditorView} from '#pm/view';
import {type SearchCounter, type SearchState, renderSearchPopup} from 'src/modules/search';

import {getReactRendererFromState} from '../ReactRenderer';

import {closeSearch, findNext, findPrev, replaceAll, replaceNext} from './commands';
import {pluginKey} from './const';
import {searchKeyHandler} from './key-handler';
import type {SearchViewState} from './types';
import {startTracking} from './utils/connect-tracker';
import {FocusManager} from './utils/focus-manager';
import {getCounter} from './utils/search-counter';

import './search-plugin.scss';

export interface SearchViewPluginParams {
anchorSelector: string;
}

export const searchViewPlugin = (params: SearchViewPluginParams) => {
return new Plugin<SearchViewState>({
key: pluginKey,
props: {
handleKeyDown: searchKeyHandler,
},
state: {
init: () => ({open: false}),
apply(tr, value, _oldState, _newState) {
const newValue = tr.getMeta(pluginKey) as SearchViewState | undefined;
if (typeof newValue === 'object') return newValue;
return value;
},
},
view(view) {
return new SeachPluginView(view, params);
},
});
};

class SeachPluginView implements PluginView {
private readonly _view: EditorView;
private readonly _renderer;
private readonly _focusManager: FocusManager;
private readonly _viewDomTrackerDispose;

private _counter: SearchCounter;
private _isDomConnected: boolean;
private _viewState: SearchViewState | undefined;
private _searchState: SearchQuery | undefined;

constructor(view: EditorView, params: SearchViewPluginParams) {
this._view = view;
this._viewState = pluginKey.getState(view.state);
this._searchState = getSearchState(view.state)?.query;
this._counter = getCounter(view.state);
this._isDomConnected = view.dom.ownerDocument.contains(view.dom);

this._renderer = this._createRenderer(params);
this._focusManager = new FocusManager(view.dom.ownerDocument);

// uses MutationObserver to detect when view.dom is disconnected from the DOM tree
// TODO: replace with eventBus (subscribe to change-editor-mode event) to track when to hide the search bar
// see https://github.com/gravity-ui/markdown-editor/issues/884
this._viewDomTrackerDispose = startTracking(view.dom, {
onConnect: this._onEditorViewDomConnected,
onDisconnect: this._onEditorViewDomDisconnected,
});
}

update() {
const newCounter = getCounter(this._view.state);
const newViewState = pluginKey.getState(this._view.state);
const newSearchState = getSearchState(this._view.state)?.query;

if (
newViewState !== this._viewState ||
newSearchState !== this._searchState ||
newCounter.total !== this._counter.total ||
newCounter.current !== this._counter.current
) {
if (
newSearchState !== this._searchState &&
newCounter.current === 0 &&
newCounter.total > 0
) {
// after changing the search query and if no match is selected, move the selection to the next match
(window.requestAnimationFrame || window.setTimeout)(() => {
this._preserveFocus(findNext);
});
}

this._counter = newCounter;
this._viewState = newViewState;
this._searchState = newSearchState;
this._renderer.rerender();
}
}

destroy() {
this._renderer.remove();
this._viewDomTrackerDispose();
}

private _onEditorViewDomConnected = () => {
this._isDomConnected = true;
this._renderer.rerender();
};

private _onEditorViewDomDisconnected = () => {
this._isDomConnected = false;
this._onClose();
};

private _createRenderer(params: Pick<SearchViewPluginParams, 'anchorSelector'>) {
return getReactRendererFromState(this._view.state).createItem('search-view', () => {
const {
_viewState: viewState,
_searchState: searchState,
_isDomConnected: domConnected,
} = this;

if (!domConnected || !viewState?.open || !searchState) return null;

const anchor = this._view.dom.ownerDocument.querySelector(params.anchorSelector);

return renderSearchPopup({
anchor,
open: viewState.open,
counter: this._counter,
state: searchState,
onClose: this._onClose,
onChange: this._onChange,
onSearchPrev: this._onSearchPrev,
onSearchNext: this._onSearchNext,
onReplaceNext: this._onReplaceNext,
onReplaceAll: this._onReplaceAll,
});
});
}

private _onChange = (config: SearchState) => {
const {state, dispatch} = this._view;

dispatch(
setSearchState(
state.tr,
new SearchQuery({
search: config.search,
replace: config.replace,
caseSensitive: config.caseSensitive,
wholeWord: config.wholeWord,
}),
),
);
};

private _onClose = () => {
closeSearch(this._view.state, this._view.dispatch);
this._view.focus();
};

private _onSearchPrev = () => {
this._preserveFocus(findPrev);
};

private _onSearchNext = () => {
this._preserveFocus(findNext);
};

private _onReplaceNext = () => {
this._preserveFocus(replaceNext);
};

private _onReplaceAll = () => {
this._preserveFocus(replaceAll);
};

private _preserveFocus(command: Command) {
this._focusManager.storeFocus();
this._view.focus();
command(this._view.state, this._view.dispatch, this._view);
this._focusManager.restoreFocus({preventScroll: true});
}
}
57 changes: 57 additions & 0 deletions src/extensions/behavior/Search/commands.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import {
SearchQuery,
findNext as findNextSearch,
findPrev as findPrevSearch,
getSearchState,
replaceAll as replaceAllSearch,
replaceNext as replaceNextSearch,
setSearchState,
} from 'prosemirror-search';

import type {Command} from '#pm/state';

import {hideSelectionMenu} from '../SelectionContext';

import {pluginKey} from './const';
import type {SearchViewState} from './types';
import {wrapCommand} from './utils/wrap-command';

export const findNext = wrapCommand(findNextSearch, hideSelectionMenu);
export const findPrev = wrapCommand(findPrevSearch, hideSelectionMenu);
export const replaceNext = wrapCommand(replaceNextSearch, hideSelectionMenu);
export const replaceAll = wrapCommand(replaceAllSearch, hideSelectionMenu);

export const openSearch: Command = (state, dispatch) => {
if (dispatch) {
const searchState = getSearchState(state);
const search = state.doc.textBetween(state.selection.from, state.selection.to, ' ');
const meta: SearchViewState = {open: true};
dispatch(
setSearchState(
hideSelectionMenu(state.tr.setMeta(pluginKey, meta)),
new SearchQuery({
...(searchState
? {
regexp: searchState.query.regexp,
replace: searchState.query.replace,
literal: searchState.query.literal,
wholeWord: searchState.query.wholeWord,
caseSensitive: searchState.query.caseSensitive,
filter: searchState.query.filter || undefined,
}
: undefined),
search,
}),
),
);
}
return true;
};

export const closeSearch: Command = (state, dispatch) => {
if (dispatch) {
const meta: SearchViewState = {open: false};
dispatch(setSearchState(state.tr.setMeta(pluginKey, meta), new SearchQuery({search: ''})));
}
return true;
};
10 changes: 10 additions & 0 deletions src/extensions/behavior/Search/const.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import {PluginKey} from '#pm/state';

import type {SearchViewState} from './types';

export const SearchClassName = {
Match: 'ProseMirror-search-match',
ActiveMatch: 'ProseMirror-active-search-match',
} as const;

export const pluginKey = new PluginKey<SearchViewState>('search-view');
1 change: 1 addition & 0 deletions src/extensions/behavior/Search/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './Search';
12 changes: 12 additions & 0 deletions src/extensions/behavior/Search/key-handler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import {keydownHandler} from '#pm/keymap';

import {closeSearch, findNext, findPrev, openSearch} from './commands';

export const searchKeyHandler = keydownHandler({
'Mod-f': openSearch,
Escape: closeSearch,
F3: findNext,
'Shift-F3': findPrev,
'Mod-g': findNext,
'Shift-Mod-g': findPrev,
});
7 changes: 7 additions & 0 deletions src/extensions/behavior/Search/search-plugin.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
.ProseMirror-search-match {
background-color: var(--g-color-base-info-light);
}

.ProseMirror-active-search-match {
background-color: var(--g-color-base-info-heavy);
}
1 change: 1 addition & 0 deletions src/extensions/behavior/Search/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export type SearchViewState = {open: boolean};
Loading
Loading