;
49 | export type SettingsPath = keyof FlatSettings;
50 |
--------------------------------------------------------------------------------
/src/settings/utils.ts:
--------------------------------------------------------------------------------
1 | import { FlatSettings, Settings, SettingsPath } from './types';
2 | import { DEFAULT_SETTINGS } from './defaults';
3 | import { DeepReadonly } from '../utility';
4 |
5 | /**
6 | * Returns the value of the given setting path.
7 | */
8 | export function getSetting(settings: DeepReadonly, path: P): FlatSettings[P] {
9 | const parts = path.split('.');
10 | let obj: unknown = settings;
11 | for (const part of parts) {
12 | obj = (obj as Record)[part];
13 | }
14 |
15 | return obj as FlatSettings[P];
16 | }
17 |
18 | /**
19 | * Sets the given setting path to the given value.
20 | */
21 | export function setSetting(settings: Settings, path: P, value: FlatSettings[P]) {
22 | const parts = path.split('.');
23 | const key = parts.pop();
24 | if (key === undefined) {
25 | throw new Error('Invalid settings path');
26 | }
27 |
28 | let obj: unknown = settings;
29 | for (const part of parts) {
30 | obj = (obj as Record)[part];
31 | }
32 |
33 | (obj as Record)[key] = value;
34 | }
35 |
36 | /**
37 | * Restores the default value for the given settings paths.
38 | */
39 | export function restoreSettings(settings: Settings, ...paths: SettingsPath[]) {
40 | for (const path of paths) {
41 | const value = getSetting(DEFAULT_SETTINGS, path);
42 | setSetting(settings, path, value);
43 | }
44 | }
45 |
46 | /**
47 | * Returns true if _all_ the given settings paths are set to their default values.
48 | */
49 | export function isDefaultSettings(settings: Settings, ...paths: SettingsPath[]): boolean {
50 | for (const path of paths) {
51 | const defaultValue = getSetting(DEFAULT_SETTINGS, path);
52 | const currentValue = getSetting(settings, path);
53 | if (defaultValue !== currentValue) {
54 | return false;
55 | }
56 | }
57 | return true;
58 | }
59 |
--------------------------------------------------------------------------------
/src/styles.scss:
--------------------------------------------------------------------------------
1 | .code {
2 | color: var(--code-normal);
3 | font-size: var(--code-size);
4 | font-family: var(--font-monospace);
5 | vertical-align: baseline;
6 | }
7 |
8 | .comment {
9 | color: var(--code-comment);
10 | }
11 |
12 | @mixin disabled {
13 | pointer-events: none;
14 | opacity: 0.35;
15 | filter: brightness(0.85);
16 | }
17 |
18 | .disabled {
19 | @include disabled;
20 | }
21 |
--------------------------------------------------------------------------------
/src/toggle/controller.ts:
--------------------------------------------------------------------------------
1 | import { Editor, EditorChange } from 'obsidian';
2 | import {
3 | buildCommentString,
4 | escapeRegex,
5 | findCodeLang,
6 | getCommentTokens,
7 | isMathBlock,
8 | shouldDenyComment,
9 | } from '../utility';
10 | import { Settings } from '../settings';
11 | import { LineState, RangeState, ToggleResult } from './types';
12 |
13 | /**
14 | * Controller for toggling line comments.
15 | */
16 | export class LineCommentController {
17 | /**
18 | * The set of uncommitted changes that have been made by this controller.
19 | */
20 | private changes: EditorChange[] = [];
21 |
22 | constructor(
23 | private editor: Editor,
24 | private settings: Settings,
25 | ) {}
26 |
27 | /**
28 | * Take the uncommitted changes that have been made by this controller.
29 | *
30 | * This will drain the list, leaving it empty and ready for future use.
31 | */
32 | public takeChanges(): EditorChange[] {
33 | const changes = this.changes;
34 | this.changes = [];
35 | return changes;
36 | }
37 |
38 | /**
39 | * Toggle the comment state of the given line.
40 | */
41 | public toggle(line: number, options: ToggleOptions = {}): ToggleResult {
42 | const original = this.editor.getLine(line);
43 | const indent = Math.max(original.search(/\S/), 0);
44 |
45 | const { state, commentStart, commentEnd } = this.lineState(line);
46 | const { isCommented, text } = state;
47 |
48 | if (commentStart.length === 0 && commentEnd.length === 0) {
49 | return { before: state, after: state, commentStart, commentEnd };
50 | }
51 |
52 | if (isCommented) {
53 | if (options.forceComment) {
54 | // Line is commented -> do nothing
55 | return { before: state, after: state, commentStart, commentEnd };
56 | }
57 |
58 | // Line is commented -> uncomment
59 | this.addChange(line, text, indent);
60 | return { before: state, after: { isCommented: false, text }, commentStart, commentEnd };
61 | } else {
62 | // Line is uncommented -> comment
63 | const newText = buildCommentString(text, commentStart, commentEnd);
64 | this.addChange(line, newText, indent);
65 | return {
66 | before: state,
67 | after: { isCommented: true, text: newText },
68 | commentStart,
69 | commentEnd,
70 | };
71 | }
72 | }
73 |
74 | /**
75 | * Get the current comment state of the given range.
76 | */
77 | public rangeState(fromLine: number, toLine: number): RangeState | null {
78 | let rangeState: RangeState | null = null;
79 | for (let line = fromLine; line <= toLine; line++) {
80 | if (shouldDenyComment(this.editor, line)) {
81 | continue;
82 | }
83 |
84 | const { isCommented } = this.lineState(line).state;
85 |
86 | switch (rangeState) {
87 | case null:
88 | rangeState = isCommented ? 'commented' : 'uncommented';
89 | break;
90 | case 'commented':
91 | rangeState = isCommented ? 'commented' : 'mixed';
92 | break;
93 | case 'uncommented':
94 | rangeState = isCommented ? 'mixed' : 'uncommented';
95 | break;
96 | case 'mixed':
97 | // Do nothing
98 | break;
99 | }
100 | }
101 |
102 | return rangeState;
103 | }
104 |
105 | /**
106 | * Get the current comment state of the given line.
107 | */
108 | private lineState(line: number): LineStateResult {
109 | const [commentStart, commentEnd] = this.getLineCommentTokens(line);
110 |
111 | const regex = this.buildCommentRegex(commentStart, commentEnd);
112 | const text = this.editor.getLine(line);
113 | const matches = regex.exec(text);
114 |
115 | if (matches === null) {
116 | return { state: { isCommented: false, text: text.trim() }, commentStart, commentEnd };
117 | }
118 |
119 | const innerText = matches[1];
120 | return { state: { isCommented: true, text: innerText.trim() }, commentStart, commentEnd };
121 | }
122 |
123 | private getLineCommentTokens(line: number): [string, string] {
124 | if (isMathBlock(this.editor, line)) {
125 | return ['%', ''];
126 | }
127 |
128 | const lang = findCodeLang(this.editor, line);
129 | return getCommentTokens(this.settings, lang);
130 | }
131 |
132 | /**
133 | * Build a regex that matches the comment tokens according to the line's context and
134 | * current {@link Settings}.
135 | *
136 | * Contains one unnamed capture group containing the text between the comment tokens.
137 | */
138 | private buildCommentRegex(commentStart: string, commentEnd: string): RegExp {
139 | const start = escapeRegex(commentStart);
140 | const end = escapeRegex(commentEnd);
141 | return new RegExp(`${start}\\s*(.*)\\s*${end}`);
142 | }
143 |
144 | /**
145 | * Add a change to the list of changes to be applied to the editor.
146 | *
147 | * @param line The affected line.
148 | * @param text The new text for the line.
149 | * @param indent The indent of the line (i.e. where to insert the text).
150 | */
151 | private addChange(line: number, text: string, indent = 0) {
152 | return this.changes.push({
153 | from: { line, ch: indent },
154 | to: { line, ch: this.editor.getLine(line).length },
155 | text,
156 | });
157 | }
158 | }
159 |
160 | interface ToggleOptions {
161 | forceComment?: boolean;
162 | }
163 |
164 | interface LineStateResult {
165 | state: LineState;
166 | commentStart: string;
167 | commentEnd: string;
168 | }
169 |
--------------------------------------------------------------------------------
/src/toggle/index.ts:
--------------------------------------------------------------------------------
1 | export { LineCommentController } from './controller';
2 | export type { ToggleResult } from './types';
3 |
--------------------------------------------------------------------------------
/src/toggle/types.ts:
--------------------------------------------------------------------------------
1 | export interface LineState {
2 | /**
3 | * Whether the line is commented or not.
4 | */
5 | isCommented: boolean;
6 | /**
7 | * The uncommented portion of the line.
8 | */
9 | text: string;
10 | }
11 |
12 | /**
13 | * The various comment states that a range of lines can be in.
14 | */
15 | export type RangeState = 'uncommented' | 'commented' | 'mixed';
16 |
17 | export interface ToggleResult {
18 | before: LineState;
19 | after: LineState;
20 | /**
21 | * The tokens used to comment the line (start).
22 | */
23 | commentStart: string;
24 | /**
25 | * The tokens used to comment the line (end).
26 | */
27 | commentEnd: string;
28 | }
29 |
--------------------------------------------------------------------------------
/src/utility/ListDict.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * A list that can be accessed by index or by key.
3 | *
4 | * Item indexes are tracked by a {@link Map}, keyed by the result of {@link toKey}.
5 | */
6 | export class ListDict {
7 | private readonly list: T[];
8 | private dict: Map;
9 |
10 | /**
11 | *
12 | * @param toKey The function to use to generate the key for an item
13 | * @param entries The initial entries to populate the list with
14 | */
15 | constructor(
16 | private toKey: (item: T) => K,
17 | entries?: Iterable,
18 | ) {
19 | this.list = entries ? Array.from(entries) : [];
20 | this.dict = new Map(this.list.map((item, i) => [toKey(item), i]));
21 | }
22 |
23 | /**
24 | * Create a {@link ListDict} that uses the item itself as the key.
25 | */
26 | public static identity(entries?: Iterable): ListDict {
27 | return new ListDict((item) => item, entries);
28 | }
29 |
30 | /**
31 | * The length of the list.
32 | */
33 | public get length() {
34 | return this.list.length;
35 | }
36 |
37 | public [Symbol.iterator]() {
38 | return this.list[Symbol.iterator]();
39 | }
40 |
41 | /**
42 | * Get the item with the given key.
43 | */
44 | public get(key: K): T | undefined {
45 | const index = this.dict.get(key);
46 | return index === undefined ? undefined : this.list[index];
47 | }
48 |
49 | /**
50 | * Get the item at the given index.
51 | */
52 | public at(index: number): T | undefined {
53 | return this.list[index];
54 | }
55 |
56 | /**
57 | * Get the index of the given item.
58 | */
59 | public indexOf(item: K): number | undefined {
60 | return this.dict.get(item);
61 | }
62 |
63 | /**
64 | * Push an item to the end of the list.
65 | */
66 | public push(item: T): number {
67 | const index = this.list.push(item) - 1;
68 | this.dict.set(this.toKey(item), index);
69 | return index;
70 | }
71 |
72 | /**
73 | * Remove and return the last item in the list.
74 | */
75 | public pop(): T | undefined {
76 | const item = this.list.pop();
77 | if (item) {
78 | this.dict.delete(this.toKey(item));
79 | }
80 | return item;
81 | }
82 |
83 | /**
84 | * Insert an item at the given index.
85 | */
86 | public insert(index: number, item: T): boolean {
87 | if (index < 0 || index > this.list.length) {
88 | return false;
89 | }
90 | this.list.splice(index, 0, item);
91 | this.dict.set(this.toKey(item), index);
92 | this.updateIndexes(index + 1);
93 | return true;
94 | }
95 |
96 | /**
97 | * Remove the item with the given key.
98 | */
99 | public remove(item: K): boolean {
100 | const index = this.dict.get(item);
101 | if (index === undefined) {
102 | return false;
103 | }
104 | this.list.splice(index, 1);
105 | this.dict.delete(item);
106 | this.updateIndexes(index);
107 |
108 | return true;
109 | }
110 |
111 | /**
112 | * Remove the item at the given index.
113 | */
114 | public removeAt(index: number): boolean {
115 | const item = this.list[index];
116 | if (item === undefined) {
117 | return false;
118 | }
119 | this.list.splice(index, 1);
120 | this.dict.delete(this.toKey(item));
121 | return true;
122 | }
123 |
124 | /**
125 | * Swap the positions of two items.
126 | *
127 | * @param itemA The key of the first item
128 | * @param itemB The key of the second item
129 | */
130 | public swap(itemA: K, itemB: K): boolean {
131 | const indexA = this.dict.get(itemA);
132 | const indexB = this.dict.get(itemB);
133 | if (indexA === undefined || indexB === undefined) {
134 | return false;
135 | }
136 | return this.swapAt(indexA, indexB);
137 | }
138 |
139 | /**
140 | * Swap the positions of two items.
141 | *
142 | * @param indexA The index of the first item
143 | * @param indexB The index of the second item
144 | */
145 | public swapAt(indexA: number, indexB: number): boolean {
146 | const itemA = this.list[indexA];
147 | const itemB = this.list[indexB];
148 | if (itemA === undefined || itemB === undefined) {
149 | return false;
150 | }
151 |
152 | this.list[indexA] = itemB;
153 | this.list[indexB] = itemA;
154 | this.dict.set(this.toKey(itemA), indexB);
155 | this.dict.set(this.toKey(itemB), indexA);
156 | return true;
157 | }
158 |
159 | /**
160 | * Remove all items from the list.
161 | */
162 | public clear(): number {
163 | const length = this.list.length;
164 | this.list.length = 0;
165 | this.dict.clear();
166 | return length;
167 | }
168 |
169 | /**
170 | * Create a new array populated with the results of calling a provided function
171 | * on every element in the list.
172 | */
173 | public map(mapper: (value: T, index: number, array: T[]) => U, thisArg?: any): U[] {
174 | return this.list.map(mapper, thisArg);
175 | }
176 |
177 | /**
178 | * Update all the mapped indexes after the given index.
179 | */
180 | private updateIndexes(startIndex = 0) {
181 | for (let i = startIndex; i < this.length; i++) {
182 | const item = this.at(i);
183 | if (item === undefined) {
184 | continue;
185 | }
186 | this.dict.set(this.toKey(item), i);
187 | }
188 | }
189 | }
190 |
--------------------------------------------------------------------------------
/src/utility/animation/AnimationGroup.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * A group of animations to play at the same time.
3 | */
4 | export class AnimationGroup {
5 | private readonly keyframes: KeyframeEffect[];
6 | private readonly longestKeyframe: KeyframeEffect | undefined;
7 |
8 | constructor(...effects: KeyframeEffect[]) {
9 | this.keyframes = Array(effects.length);
10 | let maxDuration = -Infinity;
11 |
12 | for (const effect of effects) {
13 | const duration = Number(effect.getTiming().duration) ?? 0;
14 | if (duration > maxDuration) {
15 | this.longestKeyframe = effect;
16 | maxDuration = duration;
17 | }
18 |
19 | this.keyframes.push(effect);
20 | }
21 | }
22 |
23 | /**
24 | * Plays all animations in the group.
25 | * @param callback A callback that is called when the longest animation finishes.
26 | */
27 | public play(callback: ((this: Animation, ev: AnimationPlaybackEvent) => any) | null = null) {
28 | const isReducedMotion = matchMedia('(prefers-reduced-motion: reduce)').matches;
29 | for (const keyframe of this.keyframes) {
30 | const animation = new Animation(keyframe);
31 | if (this.longestKeyframe === keyframe) {
32 | animation.onfinish = callback;
33 | }
34 |
35 | if (isReducedMotion) {
36 | animation.finish();
37 | } else {
38 | animation.play();
39 | }
40 | }
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/src/utility/animation/defaults.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Standard options for animations.
3 | */
4 | export const AnimationOptions = {
5 | get fastEaseOut() {
6 | return {
7 | duration: 150,
8 | easing: 'ease-out',
9 | };
10 | },
11 | get fasterEaseOut() {
12 | return {
13 | duration: 50,
14 | easing: 'ease-out',
15 | };
16 | },
17 | get debugEaseOut() {
18 | return {
19 | duration: 2500,
20 | easing: 'ease-out',
21 | };
22 | },
23 | } as const satisfies Record;
24 |
--------------------------------------------------------------------------------
/src/utility/animation/index.ts:
--------------------------------------------------------------------------------
1 | export * from './AnimationGroup';
2 | export * from './defaults';
3 |
--------------------------------------------------------------------------------
/src/utility/appearanceUtils.ts:
--------------------------------------------------------------------------------
1 | import { CommentAppearance } from '../settings';
2 |
3 | /**
4 | * Build a CSS style string from the given {@link CommentAppearance}.
5 | */
6 | export function buildStyleString(appearance: CommentAppearance): string {
7 | const { backgroundColor, showBackground, color, fontTheme, customFont, italic, weight, showOutline, outlineColor } =
8 | appearance;
9 |
10 | const props: string[] = [];
11 |
12 | props.push(`color: ${color}`);
13 | props.push(`font-weight: ${weight}`);
14 | italic && props.push('font-style: italic');
15 | showBackground && props.push(`background-color: ${backgroundColor}`);
16 | showOutline &&
17 | props.push(
18 | `text-shadow: ${outlineColor} -1px -1px 1px, ${outlineColor} 1px -1px 1px, ${outlineColor} -1px 1px 1px, ${outlineColor} 1px 1px 1px`,
19 | );
20 |
21 | switch (fontTheme) {
22 | case 'default':
23 | break;
24 | case 'monospace':
25 | props.push('font-family: monospace');
26 | break;
27 | case 'custom':
28 | props.push(`font-family: ${customFont}`);
29 | break;
30 | }
31 |
32 | return props.join('; ');
33 | }
34 |
--------------------------------------------------------------------------------
/src/utility/commentUtils.ts:
--------------------------------------------------------------------------------
1 | import { Settings } from '../settings';
2 | import { Languages } from '../settings/language';
3 | import { Editor } from 'obsidian';
4 | import { containsMathBlockTokens } from './editorUtils';
5 |
6 | /**
7 | * Return a tuple of the start and end comment tokens for the given {@link Settings}.
8 | */
9 | export function getCommentTokens(settings: Settings, lang: string | null): [string, string] {
10 | if (lang) {
11 | for (const { commentEnd, commentStart, regex } of settings.customLanguages) {
12 | if (regex.trim().length === 0) {
13 | continue;
14 | }
15 |
16 | if (new RegExp(regex, 'i').test(lang)) {
17 | return [commentStart, commentEnd];
18 | }
19 | }
20 |
21 | const standard = Languages.get(lang);
22 | if (standard) {
23 | return [standard.commentStart, standard.commentEnd];
24 | }
25 |
26 | // By default, don't comment anything
27 | return ['', ''];
28 | }
29 |
30 | switch (settings.commentStyle) {
31 | case 'html':
32 | return [''];
33 | case 'obsidian':
34 | return ['%%', '%%'];
35 | case 'custom':
36 | return [settings.customCommentStart, settings.customCommentEnd];
37 | default:
38 | throw new Error(`Unknown comment kind: ${settings.commentStyle}`);
39 | }
40 | }
41 |
42 | /**
43 | * Build a comment string from the given text and {@link Settings}.
44 | */
45 | export function buildCommentString(text: string, commentStart: string, commentEnd: string): string {
46 | const end = commentEnd ? ` ${commentEnd}` : '';
47 |
48 | return `${commentStart} ${text}${end}`;
49 | }
50 |
51 | /**
52 | * Returns true if the given line should not have its comment state toggled by this plugin.
53 | */
54 | export function shouldDenyComment(editor: Editor, line: number): boolean {
55 | const text = editor.getLine(line).trim();
56 | return (
57 | text.length === 0 || text.startsWith('```') || text.startsWith('$$') || containsMathBlockTokens(editor, line)
58 | );
59 | }
60 |
--------------------------------------------------------------------------------
/src/utility/editorUtils.ts:
--------------------------------------------------------------------------------
1 | import { EditorView } from '@codemirror/view';
2 | import { Editor } from 'obsidian';
3 | import { syntaxTree } from '@codemirror/language';
4 |
5 | /**
6 | * Extract a CodeMirror {@link EditorView} from an Obsidian {@link Editor}.
7 | */
8 | export function extractEditorView(editor: Editor): EditorView {
9 | // https://docs.obsidian.md/Plugins/Editor/Communicating+with+editor+extensions
10 | // @ts-expect-error: not typed
11 | return editor.cm as EditorView;
12 | }
13 |
14 | /**
15 | * Regex for extracting the language from the starting line of a code block.
16 | */
17 | const CODE_LANG_REGEX = /^```(.*)$/;
18 |
19 | /**
20 | * Find the language of the code block at the given line.
21 | *
22 | * If the line is not in a code block, returns `null`.
23 | * If it is in a code block, returns the language name (or any text following the starting "```").
24 | */
25 | export function findCodeLang(editor: Editor, line: number): string | null {
26 | const view = extractEditorView(editor);
27 |
28 | // Lines are 1-indexed so we need to add 1
29 | const linePos = view.state.doc.line(line + 1).from;
30 |
31 | // Set `side` to `1` so we only get the node starting at this line (i.e. not the topmost `Document`)
32 | const cursor = syntaxTree(view.state).cursorAt(linePos, 1);
33 |
34 | // First, check if we're in a code block
35 | if (!cursor.type.name.contains('hmd-codeblock')) {
36 | return null;
37 | }
38 |
39 | // Find the start of the code block
40 | let found = false;
41 | let { from, to } = cursor;
42 | while (line > 0) {
43 | line--;
44 | const linePos = view.state.doc.line(line + 1).from;
45 | const cursor = syntaxTree(view.state).cursorAt(linePos, 1);
46 | if (!cursor.type.name.contains('hmd-codeblock')) {
47 | found = true;
48 | break;
49 | }
50 |
51 | from = cursor.from;
52 | to = cursor.to;
53 | }
54 |
55 | if (!found) {
56 | return null;
57 | }
58 |
59 | const text = view.state.sliceDoc(from, to);
60 | const matches = CODE_LANG_REGEX.exec(text);
61 | return matches?.at(1)?.trim() ?? null;
62 | }
63 |
64 | /**
65 | * Checks if the given line is in a math block.
66 | */
67 | export function isMathBlock(editor: Editor, line: number): boolean {
68 | const view = extractEditorView(editor);
69 |
70 | // Lines are 1-indexed so we need to add 1
71 | const linePos = view.state.doc.line(line + 1).from;
72 |
73 | // Set `side` to `1` so we only get the node starting at this line (i.e. not the topmost `Document`)
74 | const cursor = syntaxTree(view.state).cursorAt(linePos, 1);
75 |
76 | return cursor.type.name.contains('math') || cursor.type.name.contains('comment_math');
77 | }
78 |
79 | /**
80 | * Returns true if the given line contains math block tokens (i.e. `$$`).
81 | *
82 | * This will check against the syntax tree to prevent false positives,
83 | * such as when the `$$` tokens are in a code block.
84 | *
85 | * This function is needed because math blocks can be defined inline.
86 | */
87 | export function containsMathBlockTokens(editor: Editor, line: number): boolean {
88 | const view = extractEditorView(editor);
89 |
90 | // Lines are 1-indexed so we need to add 1
91 | const linePos = view.state.doc.line(line + 1);
92 |
93 | for (let pos = linePos.from; pos < linePos.to; pos++) {
94 | // Set `side` to `1` so we only get the node starting at this line (i.e. not the topmost `Document`)
95 | const cursor = syntaxTree(view.state).cursorAt(pos, 1);
96 |
97 | if (
98 | cursor.type.name.contains('begin_keyword_math_math-block') ||
99 | cursor.type.name.contains('end_keyword_math_math-block')
100 | ) {
101 | return true;
102 | }
103 | }
104 |
105 | return false;
106 | }
107 |
--------------------------------------------------------------------------------
/src/utility/generalUtils.ts:
--------------------------------------------------------------------------------
1 | import { EditorPosition } from 'obsidian';
2 |
3 | /**
4 | * Escape the given text for use in a regular expression.
5 | *
6 | * @see https://stackoverflow.com/a/6969486/11571888
7 | */
8 | export function escapeRegex(text: string): string {
9 | return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
10 | }
11 |
12 | export enum Ordering {
13 | Less = -1,
14 | Equal = 0,
15 | Greater = 1,
16 | }
17 |
18 | /**
19 | * Compare two {@link EditorPosition}s.
20 | *
21 | * First compares the line numbers, then the character positions.
22 | *
23 | * The resulting {@link Ordering} always follows `a` ORDERING `b`:
24 | * - If `a` is less than `b`, the result is {@link Ordering.Less}
25 | * - If `a` is equal to `b`, the result is {@link Ordering.Equal}
26 | * - If `a` is greater than `b`, the result is {@link Ordering.Greater}
27 | */
28 | export function comparePos(a: EditorPosition, b: EditorPosition): Ordering {
29 | if (a.line < b.line) {
30 | return Ordering.Less;
31 | }
32 |
33 | if (a.line > b.line) {
34 | return Ordering.Greater;
35 | }
36 |
37 | if (a.ch < b.ch) {
38 | return Ordering.Less;
39 | }
40 |
41 | if (a.ch > b.ch) {
42 | return Ordering.Greater;
43 | }
44 |
45 | return Ordering.Equal;
46 | }
47 |
--------------------------------------------------------------------------------
/src/utility/index.ts:
--------------------------------------------------------------------------------
1 | export * from './appearanceUtils';
2 | export * from './commentUtils';
3 | export * from './editorUtils';
4 | export * from './generalUtils';
5 | export * from './typeUtils';
6 | export * from './ListDict';
7 | export * from './animation';
8 |
--------------------------------------------------------------------------------
/src/utility/typeUtils.ts:
--------------------------------------------------------------------------------
1 | type IsArray = T extends Array ? (Array extends T ? true : false) : false;
2 |
3 | type IsObject = T extends object ? (IsArray extends true ? false : true) : false;
4 |
5 | /**
6 | * Converts a union type to an intersection type.
7 | *
8 | * @see https://stackoverflow.com/a/50375286/11621047
9 | */
10 | type UnionToIntersection = (U extends unknown ? (k: U) => void : never) extends (k: infer I) => void ? I : never;
11 | /**
12 | * Converts a union type to a combined object type.
13 | *
14 | * @see https://stackoverflow.com/a/50375286/11621047
15 | */
16 | type Combine = UnionToIntersection extends infer O ? { [K in keyof O]: O[K] } : never;
17 |
18 | /**
19 | * Converts a type to a flattened representation of its leaf nodes.
20 | *
21 | * The flattened representation is a union of all fields in the object and in any nested objects,
22 | * with the keys being dot-separated paths to the leaf node type.
23 | *
24 | * @example
25 | * interface Foo {
26 | * a: string;
27 | * b: {
28 | * c: number;
29 | * };
30 | * }
31 | *
32 | * type Result = Leaves<'', Foo>;
33 | * // `Result` has the following type:
34 | * // {a: string} | {'b.c': number}
35 | */
36 | type Leaves = [keyof T] extends [infer K]
37 | ? K extends keyof T
38 | ? IsObject extends true
39 | ? Leaves<`${A}${Exclude}.`, T[K]>
40 | : { [L in `${A}${Exclude}`]: T[K] }
41 | : never
42 | : never;
43 |
44 | /**
45 | * Flattens an object and all its nested objects into a single one,
46 | * where each key is a dot-separated path to a leaf node.
47 | *
48 | * @example
49 | * interface Foo {
50 | * a: string;
51 | * b: {
52 | * c: number;
53 | * };
54 | * }
55 | *
56 | * type Result = Flatten;
57 | * // `Result` has the following type:
58 | * // {a: string; 'b.c': number}
59 | */
60 | export type Flatten = Combine>;
61 |
62 | /**
63 | * Makes all properties of an object readonly, recursively.
64 | */
65 | export type DeepReadonly = { readonly [K in keyof T]: DeepReadonly };
66 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "baseUrl": ".",
4 | "inlineSourceMap": true,
5 | "inlineSources": true,
6 | "module": "ESNext",
7 | "target": "ES6",
8 | "allowJs": true,
9 | "noImplicitAny": true,
10 | "moduleResolution": "node",
11 | "importHelpers": true,
12 | "isolatedModules": true,
13 | "strictNullChecks": true,
14 | "lib": ["DOM", "ES5", "ES6", "ES7"]
15 | },
16 | "include": ["**/*.ts"]
17 | }
18 |
--------------------------------------------------------------------------------
/version-bump.mjs:
--------------------------------------------------------------------------------
1 | import { readFileSync, writeFileSync } from 'fs';
2 |
3 | const targetVersion = process.env.npm_package_version;
4 |
5 | // read minAppVersion from manifest.json and bump version to target version
6 | let manifest = JSON.parse(readFileSync('manifest.json', 'utf8'));
7 | const { minAppVersion } = manifest;
8 | manifest.version = targetVersion;
9 | writeFileSync('manifest.json', JSON.stringify(manifest, null, '\t'));
10 |
11 | // update versions.json with target version and minAppVersion from manifest.json
12 | let versions = JSON.parse(readFileSync('versions.json', 'utf8'));
13 | versions[targetVersion] = minAppVersion;
14 | writeFileSync('versions.json', JSON.stringify(versions, null, '\t'));
15 |
--------------------------------------------------------------------------------
/versions.json:
--------------------------------------------------------------------------------
1 | {
2 | "1.0.0": "0.15.0"
3 | }
4 |
--------------------------------------------------------------------------------