@type32/codemirror-rich-obsidian-editor 0.0.22 → 0.0.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/module.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@type32/codemirror-rich-obsidian-editor",
3
3
  "configKey": "cmOfmEditor",
4
- "version": "0.0.22",
4
+ "version": "0.0.23",
5
5
  "builder": {
6
6
  "@nuxt/module-builder": "1.0.2",
7
7
  "unbuild": "3.6.1"
@@ -20,6 +20,7 @@ import wysiwyg from "../editor/wysiwyg";
20
20
  import { internalLinkMapFacet } from "../editor/plugins/linkMappingConfig";
21
21
  import { specialCodeBlockMapFacet } from "../editor/plugins/specialCodeBlockMappingConfig";
22
22
  import { customBracketClosingConfig } from "../editor/plugins/customBracketClosingConfig";
23
+ import { editorKeywordSearchPlugin, searchOptionsFacet } from "../editor/plugins/codemirror-editor-plugins/editorKeywordSearchPlugin";
23
24
  import { ref, shallowRef, computed, onMounted, onBeforeUnmount, unref, watch } from "vue";
24
25
  const doc = defineModel({ type: String });
25
26
  const props = defineProps({
@@ -29,7 +30,8 @@ const props = defineProps({
29
30
  bracketClosing: { type: Boolean, required: false },
30
31
  foldGutter: { type: Boolean, required: false },
31
32
  disabled: { type: Boolean, required: false },
32
- debug: { type: Boolean, required: false }
33
+ debug: { type: Boolean, required: false },
34
+ searchOptions: { type: Object, required: false }
33
35
  });
34
36
  const emit = defineEmits(["internal-link-click", "external-link-click"]);
35
37
  const extensions = shallowRef([]);
@@ -40,6 +42,7 @@ const specialCodeBlockCompartment = new Compartment();
40
42
  const bracketClosingCompartment = new Compartment();
41
43
  const foldGutterCompartment = new Compartment();
42
44
  const showFrontmatterCompartment = new Compartment();
45
+ const searchCompartment = new Compartment();
43
46
  const editorElement = ref();
44
47
  const keymaps = computed(() => {
45
48
  return props.disabled ? keymap.of([]) : keymap.of([...standardKeymap, ...historyKeymap, indentWithTab]);
@@ -71,6 +74,8 @@ onMounted(() => {
71
74
  specialCodeBlockCompartment.of(specialCodeBlockMapFacet.of(props.specialCodeBlockMap || [])),
72
75
  bracketClosingCompartment.of(customBracketClosingConfig.of(props.bracketClosing ?? true)),
73
76
  foldGutterCompartment.of(props.foldGutter ?? true ? foldGutter() : []),
77
+ searchCompartment.of(editorKeywordSearchPlugin),
78
+ searchCompartment.of(searchOptionsFacet.of(props.searchOptions || { query: "" })),
74
79
  wysiwygPlugin,
75
80
  EditorView.editable.of(unref(!props.disabled))
76
81
  ];
@@ -133,6 +138,17 @@ watch(
133
138
  }
134
139
  }
135
140
  );
141
+ watch(
142
+ () => props.searchOptions,
143
+ (newOptions) => {
144
+ if (view.value) {
145
+ view.value.dispatch({
146
+ effects: searchCompartment.reconfigure(searchOptionsFacet.of(newOptions || { query: "" }))
147
+ });
148
+ }
149
+ },
150
+ { deep: true }
151
+ );
136
152
  function handleReady(payload) {
137
153
  view.value = payload.view;
138
154
  }
@@ -1,5 +1,5 @@
1
1
  import { EditorView } from '@codemirror/view';
2
- import type { InternalLink, SpecialCodeBlockMapping, InternalLinkClickDetail, ExternalLinkClickDetail } from '#codemirror-rich-obsidian-editor/editor-types';
2
+ import type { InternalLink, SpecialCodeBlockMapping, InternalLinkClickDetail, ExternalLinkClickDetail, SearchOptions } from '#codemirror-rich-obsidian-editor/editor-types';
3
3
  type __VLS_Props = {
4
4
  class?: string;
5
5
  internalLinkMap?: InternalLink[];
@@ -8,6 +8,7 @@ type __VLS_Props = {
8
8
  foldGutter?: boolean;
9
9
  disabled?: boolean;
10
10
  debug?: boolean;
11
+ searchOptions?: SearchOptions;
11
12
  };
12
13
  type __VLS_PublicProps = __VLS_Props & {
13
14
  modelValue?: string;
@@ -8,4 +8,5 @@ export declare function useDocumentUtils(): {
8
8
  getAvgWordLength: (text: string) => number;
9
9
  isEmpty: (text: string) => boolean;
10
10
  getTableOfContents: (text: string) => TocEntry[];
11
+ getAllTags: (text: string) => string[];
11
12
  };
@@ -48,7 +48,8 @@ export function useDocumentUtils() {
48
48
  const textContent = text.slice(from, to).trim();
49
49
  toc.push({
50
50
  level,
51
- text: textContent
51
+ text: textContent,
52
+ node: headerMark
52
53
  });
53
54
  }
54
55
  }
@@ -57,6 +58,30 @@ export function useDocumentUtils() {
57
58
  });
58
59
  return toc;
59
60
  }
61
+ function getAllTags(text) {
62
+ const tags = [];
63
+ if (!text) return tags;
64
+ const tree = markdown({
65
+ extensions: [GFM, CustomOFM, { remove: ["SetextHeading"] }]
66
+ }).language.parser.parse(text);
67
+ let frontmatterEnd = 0;
68
+ const frontmatterNode = tree.topNode.firstChild;
69
+ if (frontmatterNode && frontmatterNode.name === "Frontmatter") {
70
+ frontmatterEnd = frontmatterNode.to;
71
+ }
72
+ tree.iterate({
73
+ from: frontmatterEnd,
74
+ enter: (node) => {
75
+ if (node.name === "Hashtag") {
76
+ const tagText = text.slice(node.from + 1, node.to);
77
+ if (tagText) {
78
+ tags.push(tagText);
79
+ }
80
+ }
81
+ }
82
+ });
83
+ return [...new Set(tags)];
84
+ }
60
85
  return {
61
86
  getWordCount,
62
87
  getLineCount,
@@ -65,6 +90,7 @@ export function useDocumentUtils() {
65
90
  getParagraphs,
66
91
  getAvgWordLength,
67
92
  isEmpty,
68
- getTableOfContents
93
+ getTableOfContents,
94
+ getAllTags
69
95
  };
70
96
  }
@@ -1,6 +1,7 @@
1
1
  import type { SyntaxNode, Tree } from '@lezer/common';
2
2
  import type { Ref } from 'vue';
3
3
  import type { TransactionSpec } from '@codemirror/state';
4
+ import type { SearchMatch, SearchOptions } from '../editor/types/editor-types.js';
4
5
  export declare function useEditorUtils(editor: Ref<any>): {
5
6
  getDoc: () => string | undefined;
6
7
  setDoc: (content: string) => void;
@@ -12,4 +13,18 @@ export declare function useEditorUtils(editor: Ref<any>): {
12
13
  findNodesByType: (tree: Tree, nodeTypeName: string) => SyntaxNode[];
13
14
  getDocNodesByType: (nodeTypeName: string) => SyntaxNode[];
14
15
  hasFrontmatter: () => boolean;
16
+ search: (options: SearchOptions) => void;
17
+ replaceAll: (replacement: string) => void;
18
+ searchResults: Ref<{
19
+ from: number;
20
+ to: number;
21
+ }[], SearchMatch[] | {
22
+ from: number;
23
+ to: number;
24
+ }[]>;
25
+ currentMatchIndex: Ref<number, number>;
26
+ findNext: () => void;
27
+ findPrevious: () => void;
28
+ replaceCurrent: (replacement: string) => void;
29
+ scrollToNode: (node: SyntaxNode) => void;
15
30
  };
@@ -1,4 +1,5 @@
1
- import { computed, unref } from "vue";
1
+ import { computed, ref, unref } from "vue";
2
+ import { EditorView } from "@codemirror/view";
2
3
  import { markdown } from "@codemirror/lang-markdown";
3
4
  import { CustomOFM } from "../../runtime/editor/lezer-parsers/customOFMParsers";
4
5
  import { GFM } from "@lezer/markdown";
@@ -8,6 +9,9 @@ export function useEditorUtils(editor) {
8
9
  if (!instance) return;
9
10
  return instance.view ?? instance;
10
11
  });
12
+ const searchResults = ref([]);
13
+ const currentMatchIndex = ref(-1);
14
+ const searchQuery = ref(null);
11
15
  function getDoc() {
12
16
  return unref(view)?.state.doc.toString();
13
17
  }
@@ -54,7 +58,95 @@ export function useEditorUtils(editor) {
54
58
  function hasFrontmatter() {
55
59
  const ast = getDocAst();
56
60
  if (!ast) return false;
57
- return ast.topNode.firstChild?.name === "YAMLFrontMatter";
61
+ const firstChild = ast.topNode.firstChild;
62
+ return firstChild?.name === "Frontmatter" || firstChild?.name === "YAMLFrontMatter";
63
+ }
64
+ function search(options) {
65
+ searchQuery.value = options;
66
+ const doc = getDoc();
67
+ if (!doc || !options.query) {
68
+ searchResults.value = [];
69
+ currentMatchIndex.value = -1;
70
+ return;
71
+ }
72
+ const matches = [];
73
+ const regex = new RegExp(options.query, options.caseSensitive ? "g" : "gi");
74
+ let match;
75
+ while ((match = regex.exec(doc)) !== null) {
76
+ matches.push({ from: match.index, to: match.index + match[0].length });
77
+ }
78
+ searchResults.value = matches;
79
+ currentMatchIndex.value = -1;
80
+ }
81
+ function selectAndScrollToMatch(match) {
82
+ const editorView = unref(view);
83
+ if (!editorView || !match) return;
84
+ editorView.dispatch({
85
+ selection: { anchor: match.from, head: match.to },
86
+ effects: EditorView.scrollIntoView(match.from, { y: "center" })
87
+ });
88
+ }
89
+ function findNext() {
90
+ if (searchResults.value.length === 0) return;
91
+ const nextIndex = (currentMatchIndex.value + 1) % searchResults.value.length;
92
+ currentMatchIndex.value = nextIndex;
93
+ if (!searchResults.value[nextIndex]) {
94
+ return;
95
+ }
96
+ selectAndScrollToMatch(searchResults.value[nextIndex]);
97
+ }
98
+ function findPrevious() {
99
+ if (searchResults.value.length === 0) return;
100
+ const prevIndex = (currentMatchIndex.value - 1 + searchResults.value.length) % searchResults.value.length;
101
+ currentMatchIndex.value = prevIndex;
102
+ if (!searchResults.value[prevIndex]) {
103
+ return;
104
+ }
105
+ selectAndScrollToMatch(searchResults.value[prevIndex]);
106
+ }
107
+ function replaceCurrent(replacement) {
108
+ if (currentMatchIndex.value < 0 || currentMatchIndex.value >= searchResults.value.length) {
109
+ findNext();
110
+ return;
111
+ }
112
+ const match = searchResults.value[currentMatchIndex.value];
113
+ if (!match) {
114
+ findNext();
115
+ return;
116
+ }
117
+ dispatch({
118
+ changes: { from: match.from, to: match.to, insert: replacement }
119
+ });
120
+ if (searchQuery.value) {
121
+ search(searchQuery.value);
122
+ }
123
+ }
124
+ function replaceAll(replacement) {
125
+ if (!searchQuery.value || !searchQuery.value.query) return;
126
+ const doc = getDoc();
127
+ if (!doc) return;
128
+ const matches = [];
129
+ const regex = new RegExp(searchQuery.value.query, searchQuery.value.caseSensitive ? "g" : "gi");
130
+ let match;
131
+ while ((match = regex.exec(doc)) !== null) {
132
+ matches.push({ from: match.index, to: match.index + match[0].length });
133
+ }
134
+ if (matches.length === 0) return;
135
+ const changes = matches.map((m) => ({
136
+ from: m.from,
137
+ to: m.to,
138
+ insert: replacement
139
+ }));
140
+ dispatch({ changes });
141
+ searchResults.value = [];
142
+ currentMatchIndex.value = -1;
143
+ }
144
+ function scrollToNode(node) {
145
+ const editorView = unref(view);
146
+ if (!editorView || !node) return;
147
+ editorView.dispatch({
148
+ effects: EditorView.scrollIntoView(node.from, { y: "center" })
149
+ });
58
150
  }
59
151
  return {
60
152
  getDoc,
@@ -66,6 +158,14 @@ export function useEditorUtils(editor) {
66
158
  getDocAst,
67
159
  findNodesByType,
68
160
  getDocNodesByType,
69
- hasFrontmatter
161
+ hasFrontmatter,
162
+ search,
163
+ replaceAll,
164
+ searchResults,
165
+ currentMatchIndex,
166
+ findNext,
167
+ findPrevious,
168
+ replaceCurrent,
169
+ scrollToNode
70
170
  };
71
171
  }
@@ -25,16 +25,33 @@ function internalLinkSource(context) {
25
25
  }
26
26
  }
27
27
  const linkMap = context.state.facet(internalLinkMapFacet);
28
- const options = linkMap.filter((link) => link.internalLinkName.toLowerCase().includes(textBefore.toLowerCase())).map((link) => ({
29
- label: link.internalLinkName,
30
- detail: link.filePath,
31
- apply: `${link.internalLinkName}`
32
- }));
28
+ const nameCounts = /* @__PURE__ */ new Map();
29
+ linkMap.forEach((link) => {
30
+ nameCounts.set(link.internalLinkName, (nameCounts.get(link.internalLinkName) || 0) + 1);
31
+ });
32
+ const searchString = textBefore.toLowerCase();
33
+ const options = linkMap.filter(
34
+ (link) => link.internalLinkName.toLowerCase().includes(searchString) || link.filePath && link.filePath.toLowerCase().includes(searchString)
35
+ ).map((link) => {
36
+ const isDuplicate = (nameCounts.get(link.internalLinkName) || 0) > 1;
37
+ if (isDuplicate) {
38
+ return {
39
+ label: link.filePath || link.internalLinkName,
40
+ detail: link.internalLinkName,
41
+ apply: link.filePath || link.internalLinkName
42
+ };
43
+ }
44
+ return {
45
+ label: link.internalLinkName,
46
+ detail: link.filePath,
47
+ apply: `${link.internalLinkName}`
48
+ };
49
+ });
33
50
  if (options.length === 0) return null;
34
51
  return {
35
52
  from,
36
53
  options,
37
- validFor: /^[\w\s]*$/
54
+ validFor: /^[^\]|]*/
38
55
  };
39
56
  }
40
57
  export const editorInternalLinkAutocompletePlugin = autocompletion({
@@ -0,0 +1,5 @@
1
+ import { StateField, Facet } from '@codemirror/state';
2
+ import type { DecorationSet } from '@codemirror/view';
3
+ import type { SearchOptions } from '../../types/editor-types.js';
4
+ export declare const searchOptionsFacet: Facet<SearchOptions, SearchOptions>;
5
+ export declare const editorKeywordSearchPlugin: StateField<DecorationSet>;
@@ -0,0 +1,38 @@
1
+ import { Decoration, EditorView } from "@codemirror/view";
2
+ import { StateField, RangeSet, Facet } from "@codemirror/state";
3
+ export const searchOptionsFacet = Facet.define({
4
+ combine: (values) => values[0] || { query: "", caseSensitive: false }
5
+ });
6
+ const highlightMark = Decoration.mark({ class: "cm-highlight-keyword" });
7
+ function findKeywords(doc, { query, caseSensitive }) {
8
+ const decorations = [];
9
+ if (!query) return decorations;
10
+ const flags = caseSensitive ? "g" : "gi";
11
+ const regex = new RegExp(query, flags);
12
+ for (let i = 1; i <= doc.lines; i++) {
13
+ const line = doc.line(i);
14
+ for (const match of line.text.matchAll(regex)) {
15
+ if (match.index !== void 0) {
16
+ const from = line.from + match.index;
17
+ const to = from + match[0].length;
18
+ decorations.push(highlightMark.range(from, to));
19
+ }
20
+ }
21
+ }
22
+ return decorations;
23
+ }
24
+ export const editorKeywordSearchPlugin = StateField.define({
25
+ create(state) {
26
+ const options = state.facet(searchOptionsFacet);
27
+ return RangeSet.of(findKeywords(state.doc, options));
28
+ },
29
+ update(value, tr) {
30
+ const options = tr.state.facet(searchOptionsFacet);
31
+ const oldOptions = tr.startState.facet(searchOptionsFacet);
32
+ if (tr.docChanged || options.query !== oldOptions.query || options.caseSensitive !== oldOptions.caseSensitive) {
33
+ return RangeSet.of(findKeywords(tr.state.doc, options));
34
+ }
35
+ return value.map(tr.changes);
36
+ },
37
+ provide: (f) => EditorView.decorations.from(f)
38
+ });
@@ -22,7 +22,7 @@ function buildInternalLinkDecorations(state) {
22
22
  const pathNode = node.node.getChild("InternalLink")?.getChild("InternalPath");
23
23
  if (pathNode) {
24
24
  const path = state.doc.sliceString(pathNode.from, pathNode.to);
25
- const linkInfo = linkMap.find((l) => l.internalLinkName === path);
25
+ const linkInfo = linkMap.find((l) => l.internalLinkName === path || l.filePath === path);
26
26
  if (linkInfo?.embedComponent) {
27
27
  const line = state.doc.lineAt(node.from);
28
28
  const props = { linkData: linkInfo };
@@ -58,7 +58,7 @@ function buildInternalLinkDecorations(state) {
58
58
  const aliasNode = contentContainerNode.getChild("InternalDisplay");
59
59
  const subpath = subpathNode ? state.doc.sliceString(subpathNode.from, subpathNode.to) : void 0;
60
60
  const alias = aliasNode ? state.doc.sliceString(aliasNode.from, aliasNode.to) : void 0;
61
- const linkInfo = linkMap.find((l) => l.internalLinkName === path);
61
+ const linkInfo = linkMap.find((l) => l.internalLinkName === path || l.filePath === path);
62
62
  if (node.name === "Embed" && linkInfo?.embedComponent) {
63
63
  return false;
64
64
  }
@@ -1,5 +1,6 @@
1
1
  import type { Component } from 'vue'
2
2
  import type { LanguageSupport } from '@codemirror/language'
3
+ import type { SyntaxNode } from '@lezer/common'
3
4
 
4
5
  export interface InternalLink {
5
6
  internalLinkName: string;
@@ -55,5 +56,16 @@ export type UnicodeRange = number[][]
55
56
 
56
57
  export interface TocEntry {
57
58
  level: number
58
- text: string
59
+ text: string,
60
+ node: SyntaxNode
61
+ }
62
+
63
+ export interface SearchMatch {
64
+ from: number;
65
+ to: number;
66
+ }
67
+
68
+ export interface SearchOptions {
69
+ query: string;
70
+ caseSensitive?: boolean;
59
71
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@type32/codemirror-rich-obsidian-editor",
3
- "version": "0.0.22",
3
+ "version": "0.0.23",
4
4
  "description": "OFM Editor Component for Nuxt.",
5
5
  "repository": "Type-32/codemirror-rich-obsidian",
6
6
  "license": "MIT",