From 1a9cd34dc2e6b99e56272b277b1cfb100814d102 Mon Sep 17 00:00:00 2001 From: Fevol Date: Fri, 4 Aug 2023 17:13:35 +0200 Subject: [PATCH] feat: basic in-development comment gutter --- src/assets/main.scss | 51 ++- src/editor/renderers/comment-gutter.ts | 119 ++++++ src/editor/renderers/live-preview.ts | 56 ++- src/editor/renderers/right-gutter.ts | 506 +++++++++++++++++++++++++ src/main.ts | 7 +- 5 files changed, 718 insertions(+), 21 deletions(-) create mode 100644 src/editor/renderers/comment-gutter.ts create mode 100644 src/editor/renderers/right-gutter.ts diff --git a/src/assets/main.scss b/src/assets/main.scss index 71fed68..998201f 100644 --- a/src/assets/main.scss +++ b/src/assets/main.scss @@ -55,9 +55,18 @@ padding: 0 0 var(--size-4-2) 0; } +.criticmarkup-comment-icon:hover { + cursor: pointer; +} + .criticmarkup-comment-icon svg { color: var(--comment-border-color); stroke-width: 4px; + transition: stroke-width 150ms ease-in-out; +} + +.criticmarkup-comment-icon:hover svg { + stroke-width: 5px; } .criticmarkup-comment-tooltip { @@ -78,11 +87,11 @@ border-radius: var(--radius-l); } -.criticmarkup-comment-tooltip p { - display: inline; +.criticmarkup-comment-tooltip * { + margin-block-start: 0.25em; + margin-block-end: 0.25em; } - .criticmarkup-highlight { background-color: var(--highlight-color); } @@ -164,6 +173,42 @@ +.criticmarkup-comment-gutter { + width: 300px; + margin-top: 50px; +} + +.criticmarkup-gutter-comment { + color: var(--text-muted); + border: 2px solid var(--background-modifier-border); + border-radius: var(--radius-l); + padding: var(--size-4-2); + transition: border-color 200ms ease-in-out; + background-color: var(--background-secondary-alt); + + // Position comments relative to their optical center + //transform: translateY(-25%); + + max-height: 150px; + overflow-y: scroll; +} + + +//.criticmarkup-comment-gutter .cm-gutterElement { +// margin-bottom: 10px; +//} + +.criticmarkup-gutter-comment * { + margin-block-start: 0.25em; + margin-block-end: 0.25em; +} + + +.criticmarkup-gutter-comment:focus { + border: 2px solid var(--comment-border-color); + background-color: var(--background-primary); +} + // ================================================ // Markdown-specific Fixes diff --git a/src/editor/renderers/comment-gutter.ts b/src/editor/renderers/comment-gutter.ts new file mode 100644 index 0000000..6b59968 --- /dev/null +++ b/src/editor/renderers/comment-gutter.ts @@ -0,0 +1,119 @@ +import { RangeSet, RangeSetBuilder, StateField } from '@codemirror/state'; +import { EditorView, GutterMarker } from '@codemirror/view'; +import { NodeType, PluginSettings } from '../../types'; + +import { treeParser } from '../tree-parser'; + +import { nodesInSelection } from '../editor-util'; +import { CriticMarkupNode, CriticMarkupNodes } from '../criticmarkup-nodes'; +import { right_gutter } from './right-gutter'; +import { Component, editorEditorField, MarkdownRenderer } from 'obsidian'; + + +// TODO: Rerender gutter on Ctrl+Scroll + +export class CriticMarkupMarker extends GutterMarker { + node: CriticMarkupNode; + comment: HTMLElement | null = null; + view: EditorView; + + constructor(node: CriticMarkupNode, view: EditorView) { + super(); + this.node = node; + this.view = view; + } + + toDOM() { + let class_list = ''; + + this.comment = createDiv({ cls: class_list }); + this.comment.contentEditable = 'true'; + + const component = new Component(); + this.comment.classList.add('criticmarkup-gutter-comment'); + const contents = this.view.state.doc.sliceString(this.node.from + 3, this.node.to - 3); + MarkdownRenderer.renderMarkdown(contents, this.comment, '', component); + + this.comment.onblur = () => { + setTimeout(() => this.view.dispatch({ + changes: { + from: this.node.from + 3, + to: this.node.to - 3, + insert: this.comment!.innerText + } + })); + } + this.comment.onfocus = (e) => { + this.comment!.innerText = contents; + + const top = this.view.lineBlockAt(this.node.from).top; + this.view.scrollDOM.scrollTo({ top, behavior: 'smooth'}); + + + // TODO: Call a function inside the gutter to trigger movement of markers + + // Get a reference to the Gutter extension + // @ts-ignore + // console.log(this.view.plugin(commentGutterExtension)) + // const gutter = ; + + + } + + + component.load(); + + return this.comment; + } + + focus() { + this.comment!.focus(); + } +} + +export const commentGutterWidgets = StateField.define>({ + create() { + return RangeSet.empty; + }, + update(oldSet, tr) { + if (!tr.docChanged && oldSet.size) + return oldSet; + + const tree = tr.state.field(treeParser).tree; + const builder = new RangeSetBuilder(); + const nodes: CriticMarkupNodes = nodesInSelection(tree); + const view = tr.state.field(editorEditorField); + + for (const node of nodes.nodes) { + if (node.type !== NodeType.COMMENT) continue; + + const line_from = tr.state.doc.lineAt(node.from); + builder.add(line_from.from, line_from.to, new CriticMarkupMarker(node, view)); + } + + return builder.finish(); + }, + + // provide(field: StateField): Extension { + // return (field); + // } +}); + +export const commentGutterExtension = /*(settings: PluginSettings) =>*/ [ + commentGutterWidgets, + right_gutter({ + class: 'criticmarkup-comment-gutter', + markers: v => v.state.field(commentGutterWidgets), + }), +]; + + +// export const commentGutterExtension = (settings: PluginSettings) => right_gutter({ +// class: 'criticmarkup-comment-gutter', +// markers(view: EditorView) { +// return buildMarkers(view, view.state.field(treeParser).tree) ?? RangeSet.empty; +// }, +// doX() { +// console.log("xxx") +// } +// }); diff --git a/src/editor/renderers/live-preview.ts b/src/editor/renderers/live-preview.ts index 71a2c3e..c2272c8 100644 --- a/src/editor/renderers/live-preview.ts +++ b/src/editor/renderers/live-preview.ts @@ -13,14 +13,21 @@ import { WidgetType, } from '@codemirror/view'; -import { Component, editorLivePreviewField, MarkdownRenderer, setIcon } from 'obsidian'; +import { + Component, + editorLivePreviewField, + MarkdownRenderer, + setIcon, +} from 'obsidian'; import type { PluginSettings } from '../../types'; import { NodeType } from '../../types'; import { nodesInSelection, selectionRangeOverlap } from '../editor-util'; import { CriticMarkupNode, SubstitutionNode } from '../criticmarkup-nodes'; +import { commentGutterWidgets } from './comment-gutter'; + -export const inlineCommentRenderer = StateField.define({ +export const inlineCommentRenderer = (settings: PluginSettings) => StateField.define({ create(state): DecorationSet { return Decoration.none; }, @@ -50,7 +57,7 @@ export const inlineCommentRenderer = StateField.define({ node.from, node.to, Decoration.replace({ - widget: new CommentIconWidget(tr.state.sliceDoc(node.from + 3, node.to - 3)), + widget: new CommentIconWidget(node, tr.state.sliceDoc(node.from + 3, node.to - 3), settings.comment_style === "block"), }) ); } @@ -71,13 +78,17 @@ class CommentIconWidget extends WidgetType { tooltip: HTMLElement | null = null; icon: HTMLElement | null = null; + node: CriticMarkupNode; + component: Component; focused = false; + is_block = false; - - constructor(contents: string) { + constructor(node: CriticMarkupNode, contents: string, is_block = false) { super(); + this.node = node; this.contents = contents; + this.is_block = is_block; this.component = new Component(); } @@ -110,16 +121,29 @@ class CommentIconWidget extends WidgetType { this.icon.classList.add('criticmarkup-comment-icon'); setIcon(this.icon, 'message-square'); - this.icon.onmouseenter = () => { this.renderTooltip(); } - this.icon.onclick = () => { - this.renderTooltip(); - this.focused = true; - } + if (this.is_block) { + this.icon.onclick = (e) => { + const gutterElements = view.state.field(commentGutterWidgets); + gutterElements.between(this.node.from, this.node.to, (from, to, widget) => { + widget.focus(); + }); + }; + } else { + if (this.contents.length) { + this.icon.onmouseenter = () => { + this.renderTooltip(); + } + this.icon.onclick = () => { + this.renderTooltip(); + this.focused = true; + } - this.icon.onmouseleave = () => { - this.unrenderTooltip(); - // TODO: Find a better way to check if the tooltip is still focused (requires a document.click listener -> expensive?); .onblur does not work - this.focused = false; + this.icon.onmouseleave = () => { + this.unrenderTooltip(); + // TODO: Find a better way to check if the tooltip is still focused (requires a document.click listener -> expensive?); .onblur does not work + this.focused = false; + } + } } // this.icon.onblur = () => { @@ -198,8 +222,6 @@ function markContents(decorations: Range[], node: CriticMarkupNode, } } - - export const livePreviewRenderer = (settings: PluginSettings) => StateField.define({ create(state): DecorationSet { return Decoration.none; @@ -218,7 +240,7 @@ export const livePreviewRenderer = (settings: PluginSettings) => StateField.defi for (const node of nodes.nodes) { if (!settings.preview_mode) { - if (tr.selection?.ranges?.some(range => node.partially_in_range(range.from, range.to))) { + if (!settings.suggest_mode && tr.selection?.ranges?.some(range => node.partially_in_range(range.from, range.to))) { markContents(decorations, node, 'criticmarkup-editing'); } else if (node.type === NodeType.SUBSTITUTION) { removeBracket(decorations, node, true); diff --git a/src/editor/renderers/right-gutter.ts b/src/editor/renderers/right-gutter.ts new file mode 100644 index 0000000..da7d07b --- /dev/null +++ b/src/editor/renderers/right-gutter.ts @@ -0,0 +1,506 @@ +import { Facet, Extension, RangeSet, RangeCursor } from '@codemirror/state'; +import { + EditorView, + ViewPlugin, + ViewUpdate, + BlockInfo, + BlockType, + WidgetType, + Direction, + GutterMarker, +} from '@codemirror/view'; +import { request } from 'obsidian'; + +/// Facet used to add a class to all gutter elements for a given line. +/// Markers given to this facet should _only_ define an +/// [`elementclass`](#view.GutterMarker.elementClass), not a +/// [`toDOM`](#view.GutterMarker.toDOM) (or the marker will appear +/// in all gutters for the line). +export const gutterLineClass = Facet.define>(); + +type Handlers = { [event: string]: (view: EditorView, line: BlockInfo, event: Event) => boolean } + +interface GutterConfig { + /** An extra CSS class to be added to the wrapper (`cm-gutter`) element. */ + class?: string + /** Controls whether empty gutter elements should be rendered. + Defaults to false. */ + renderEmptyElements?: boolean + /** Retrieve a set of markers to use in this gutter. */ + markers?: (view: EditorView) => (RangeSet | readonly RangeSet[]) + /** Can be used to optionally add a single marker to every line. */ + lineMarker?: (view: EditorView, line: BlockInfo, otherMarkers: readonly GutterMarker[]) => GutterMarker | null + /** Associate markers with block widgets in the document. */ + widgetMarker?: (view: EditorView, widget: WidgetType, block: BlockInfo) => GutterMarker | null + /** If line or widget markers depend on additional state, and should + * be updated when that changes, pass a predicate here that checks + * whether a given view update might change the line markers. */ + lineMarkerChange?: null | ((update: ViewUpdate) => boolean) + /** Add a hidden spacer element that gives the gutter its base width. */ + initialSpacer?: null | ((view: EditorView) => GutterMarker) + /** Update the spacer element when the view is updated. */ + updateSpacer?: null | ((spacer: GutterMarker, update: ViewUpdate) => GutterMarker) + /** Supply event handlers for DOM events on this gutter. */ + domEventHandlers?: Handlers, +} + +const defaults = { + class: '', + renderEmptyElements: false, + elementStyle: '', + markers: () => RangeSet.empty, + lineMarker: () => null, + widgetMarker: () => null, + lineMarkerChange: null, + initialSpacer: null, + updateSpacer: null, + domEventHandlers: {}, +}; + +const activeGutters = Facet.define>(); + +/** Define an editor gutter. The order in which the gutters appear is + determined by their extension priority. */ +export function right_gutter(config: GutterConfig): Extension { + return [right_gutters(), activeGutters.of({ ...defaults, ...config })]; +} + +const unfixGutters = Facet.define({ + combine: values => values.some(x => x), +}); + +/** The gutter-drawing plugin is automatically enabled when you add a + gutter, but you can use this function to explicitly configure it. + + Unless `fixed` is explicitly set to `false`, the gutters are + fixed, meaning they don't scroll along with the content + horizontally (except on Internet Explorer, which doesn't support + CSS [`position: + sticky`](https://developer.mozilla.org/en-US/docs/Web/CSS/position#sticky)). */ +export function right_gutters(config?: { fixed?: boolean }): Extension { + const result: Extension[] = [ + gutterView, + ]; + if (config && config.fixed === false) result.push(unfixGutters.of(true)); + return result; +} + +const gutterView = ViewPlugin.fromClass(class { + gutters: SingleGutterView[]; + dom: HTMLElement; + fixed: boolean; + prevViewport: { from: number, to: number }; + + constructor(readonly view: EditorView) { + this.prevViewport = view.viewport; + this.dom = document.createElement('div'); + this.dom.className = 'cm-gutters'; + this.dom.setAttribute('aria-hidden', 'true'); + this.dom.style.minHeight = this.view.contentHeight + 'px'; + this.gutters = view.state.facet(activeGutters).map(conf => new SingleGutterView(view, conf)); + for (const gutter of this.gutters) this.dom.appendChild(gutter.dom); + this.fixed = !view.state.facet(unfixGutters); + if (this.fixed) { + // FIXME IE11 fallback, which doesn't support position: sticky, + // by using position: relative + event handlers that realign the + // gutter (or just force fixed=false on IE11?) + this.dom.style.position = 'sticky'; + } + + // TODO: None of the marker Elements are rendered at this point, does this happen in syncGutters or does it happen on update + + this.syncGutters(false); + // MODIFICATION: Added nextSibling to view.contentDOM + view.scrollDOM.insertBefore(this.dom, view.contentDOM.nextSibling); + } + + update(update: ViewUpdate) { + // updateGutters executes the viewUpdate on the active gutters + if (this.updateGutters(update)) { + // If (and only if) the gutters have changed in a meaningful way -- i.e. markers got removed/added within SingleGutterView + // Then need to rerender these positions + + /** Detach during sync when the viewport changed significantly + (such as during scrolling), since for large updates that is faster. */ + const vpA = this.prevViewport, vpB = update.view.viewport; + const vpOverlap = Math.min(vpA.to, vpB.to) - Math.max(vpA.from, vpB.from); + this.syncGutters(vpOverlap < (vpB.to - vpB.from) * 0.8); + } + if (update.geometryChanged) this.dom.style.minHeight = this.view.contentHeight + 'px'; + if (this.view.state.facet(unfixGutters) != !this.fixed) { + this.fixed = !this.fixed; + this.dom.style.position = this.fixed ? 'sticky' : ''; + } + this.prevViewport = update.view.viewport; + } + + syncGutters(detach: boolean) { + // Detach is almost always true except for the construction of the function (?) + + // after is a hidden element AFTER the gutter containing nothing + // Not sure what the usage is + const after = this.dom.nextSibling; + + // Always detach -> Always fully rerender all SingleGutterViews and 'big' gutter + if (detach) this.dom.remove(); + + + const lineClasses = RangeSet.iter(this.view.state.facet(gutterLineClass), this.view.viewport.from); + let classSet: GutterMarker[] = []; + + // Prepares context for each gutter (with individual cursor, height and ...) + const contexts = this.gutters.map(gutter => new UpdateContext(gutter, this.view.viewport, -this.view.documentPadding.top)); + + // Loop over all blocks (lines) in the viewport + for (const line of this.view.viewportLineBlocks) { + if (classSet.length) classSet = []; + + + // ??? If line consists of multiple blocks, does not happen + if (Array.isArray(line.type)) { + let first = true; + for (const b of line.type) { + if (b.type == BlockType.Text && first) { + advanceCursor(lineClasses, classSet, b.from); + for (const cx of contexts) cx.line(this.view, b, classSet); + first = false; + } else if (b.widget) { + for (const cx of contexts) cx.widget(this.view, b); + } + } + } + + // If block consists of text + else if (line.type == BlockType.Text) { + // ??? Advance cursor to line (might do nothing?) + advanceCursor(lineClasses, classSet, line.from); + + // For each gutter update context, call line + for (const cx of contexts) + cx.line(this.view, line, classSet); + } + } + + // ??? + for (const cx of contexts) + cx.finish(); + + // Re-insert the DOM gutter + if (detach) this.view.scrollDOM.insertBefore(this.dom, after); + } + + updateGutters(update: ViewUpdate) { + const prev = update.startState.facet(activeGutters), cur = update.state.facet(activeGutters); + let change = update.docChanged || update.heightChanged || update.viewportChanged || + !RangeSet.eq(update.startState.facet(gutterLineClass), update.state.facet(gutterLineClass), + update.view.viewport.from, update.view.viewport.to); + if (prev == cur) { + // Updates all gutters, results in syncGutters if change === True + for (const gutter of this.gutters) if (gutter.update(update)) change = true; + } else { + // This code only executes on gutter being added or removed (specifically: switching source/LP mode?) + change = true; + const gutters = []; + for (const conf of cur) { + const known = prev.indexOf(conf); + if (known < 0) { + gutters.push(new SingleGutterView(this.view, conf)); + } else { + this.gutters[known].update(update); + gutters.push(this.gutters[known]); + } + } + for (const g of this.gutters) { + g.dom.remove(); + if (gutters.indexOf(g) < 0) g.destroy(); + } + for (const g of gutters) this.dom.appendChild(g.dom); + this.gutters = gutters; + } + return change; + } + + moveGutter(marker: GutterMarker, position: number) { + + } + + destroy() { + for (const view of this.gutters) view.destroy(); + this.dom.remove(); + } +}, { + provide: plugin => EditorView.scrollMargins.of(view => { + const value = view.plugin(plugin); + if (!value || value.gutters.length == 0 || !value.fixed) return null; + return view.textDirection == Direction.LTR ? { left: value.dom.offsetWidth } : { right: value.dom.offsetWidth }; + }), +}); + +function asArray(val: T | readonly T[]) { + return (Array.isArray(val) ? val : [val]) as readonly T[]; +} + +function advanceCursor(cursor: RangeCursor, collect: GutterMarker[], pos: number) { + while (cursor.value && cursor.from <= pos) { + if (cursor.from == pos) collect.push(cursor.value); + cursor.next(); + } +} + +function getElementHeight(element: HTMLElement): Promise { + return new Promise(resolve => { + requestAnimationFrame(() => { + const height = element.offsetHeight; + resolve(height); + }); + }); +} + + +class UpdateContext { + cursor: RangeCursor; + + // TODO: Understand i + i = 0; + + previousEnd: number = 0; + + /** + * Amount of padding added to the top of the document + */ + height: number = 0; + + constructor(readonly gutter: SingleGutterView, viewport: { from: number, to: number }, height: number) { + this.cursor = RangeSet.iter(gutter.markers, viewport.from); + this.height = height; + } + + /** + * Add markers to block + * @param view - Current EditorView + * @param block - Info of block to which markers should be added + * @param markers - Markers to add + */ + async addElement(view: EditorView, block: BlockInfo, markers: readonly GutterMarker[]) { + const { gutter } = this; + // Above gives the margin between either top of document or bottom previous block + // const above = block.top < this.height ? this.height : block.top - this.height; + // const above = block.top - this.height; + const above = Math.max(block.top - this.height, 0); + const block_start = block.top < this.height ? this.height : block.top; + + + // TODO: In here, all the code should be managed for setting the location + + const custom_block_height = Math.max(150, 44 * markers.length); + + + // Constructs element if gutter was initialised from empty + if (this.i == gutter.elements.length) { + // Create a new Gutter Element at position + const newElt = new GutterElement(view, custom_block_height, above, markers); + gutter.elements.push(newElt); + + + gutter.dom.appendChild(newElt.dom); + + // TODO: get height of gutterElement as part of above + + } + + // Update element (move up/down) if gutter already exists + else { + gutter.elements[this.i].update(view, custom_block_height, above, markers); + } + + // console.log(`ABV: ${above}\t`, `| BLT ${block.top}\t`, `| BLB ${block.bottom}\t`, `| BLH ${block.bottom - block.top}\t`, `| HIG ${this.height}\t`, `| NHI ${block.bottom - block.height + custom_block_height}`); + + this.height = block_start + custom_block_height; + + // this.previousEnd = block.top + ELEMENT.height: + + + this.i++; + } + + line(view: EditorView, line: BlockInfo, extraMarkers: readonly GutterMarker[]) { + let localMarkers: GutterMarker[] = []; + // advanceCursor will place all GutterMarkers between this.cursor and line.from into localMarkers + advanceCursor(this.cursor, localMarkers, line.from); + + // never happens + if (extraMarkers.length) localMarkers = localMarkers.concat(extraMarkers); + + // Only happens when we set lineMarker in config + const forLine = this.gutter.config.lineMarker(view, line, localMarkers); + if (forLine) localMarkers.unshift(forLine); + + const gutter = this.gutter; + if (localMarkers.length == 0 && !gutter.config.renderEmptyElements) return; + this.addElement(view, line, localMarkers); + } + + widget(view: EditorView, block: BlockInfo) { + // @ts-ignore + const marker = this.gutter.config.widgetMarker(view, block.widget!, block); + if (marker) this.addElement(view, block, [marker]); + } + + finish() { + const gutter = this.gutter; + // ??? While length of gutter elements greater than i, remove last gutter element + // ??? Removes element if it exists the gutter + // FIXME: This might cause issues if there are a great many gutter elements + while (gutter.elements.length > this.i) { + const last = gutter.elements.pop()!; + gutter.dom.removeChild(last.dom); + last.destroy(); + } + } +} + +class SingleGutterView { + // This class is just a constructor/container of the gutterelements, all the updates in UpdateContext instead + // Manages: + // - Marker creation using current view + // - Checking whether update should be executed on viewport change + + dom: HTMLElement; + elements: GutterElement[] = []; + markers: readonly RangeSet[]; + spacer: GutterElement | null = null; + + constructor(public view: EditorView, public config: Required) { + // Initialised dom for the gutter + this.dom = document.createElement('div'); + this.dom.className = 'cm-gutter' + (this.config.class ? ' ' + this.config.class : ''); + for (const prop in config.domEventHandlers) { + this.dom.addEventListener(prop, (event: Event) => { + let target = event.target as HTMLElement, y; + if (target != this.dom && this.dom.contains(target)) { + while (target.parentNode != this.dom) target = target.parentNode as HTMLElement; + const rect = target.getBoundingClientRect(); + y = (rect.top + rect.bottom) / 2; + } else { + y = (event as MouseEvent).clientY; + } + const line = view.lineBlockAtHeight(y - view.documentTop); + if (config.domEventHandlers[prop](view, line, event)) event.preventDefault(); + }); + } + // Constructs markers as rangeSet + this.markers = asArray(config.markers(view)); + if (config.initialSpacer) { + this.spacer = new GutterElement(view, 0, 0, [config.initialSpacer(view)]); + this.dom.appendChild(this.spacer.dom); + this.spacer.dom.style.cssText += 'visibility: hidden; pointer-events: none'; + } + } + + update(update: ViewUpdate) { + const prevMarkers = this.markers; + this.markers = asArray(this.config.markers(update.view)); + + // Now we have two sets of markers: prevMarkers and this.markers + + if (this.spacer && this.config.updateSpacer) { + const updated = this.config.updateSpacer(this.spacer.markers[0], update); + if (updated != this.spacer.markers[0]) this.spacer.update(update.view, 0, 0, [updated]); + } + const vp = update.view.viewport; + + // Boolean returns true only if markers have changed within the viewport (so outside markers don't count) + return !RangeSet.eq(this.markers, prevMarkers, vp.from, vp.to) || + (this.config.lineMarkerChange ? this.config.lineMarkerChange(update) : false); + } + + destroy() { + for (const elt of this.elements) elt.destroy(); + } +} + +class GutterElement { + dom: HTMLElement; + height: number = -1; + above: number = 0; + markers: readonly GutterMarker[] = []; + + constructor(view: EditorView, height: number, above: number, markers: readonly GutterMarker[]) { + this.dom = document.createElement('div'); + this.dom.className = 'cm-gutterElement'; + this.update(view, height, above, markers); + } + + update(view: EditorView, height: number, above: number, markers: readonly GutterMarker[]) { + if (this.height != height) + this.dom.style.height = (this.height = height) + 'px'; + if (this.above != above) + this.dom.style.marginTop = (this.above = above) ? above + 'px' : ''; + if (!sameMarkers(this.markers, markers)) { + this.setMarkers(view, markers); + } + } + + setMarkers(view: EditorView, markers: readonly GutterMarker[]) { + let cls = 'cm-gutterElement', domPos = this.dom.firstChild; + for (let iNew = 0, iOld = 0; ;) { + let skipTo = iOld, marker = iNew < markers.length ? markers[iNew++] : null, matched = false; + if (marker) { + const c = marker.elementClass; + if (c) cls += ' ' + c; + for (let i = iOld; i < this.markers.length; i++) + // @ts-ignore + if (this.markers[i].compare(marker)) { + skipTo = i; + matched = true; + break; + } + } else { + skipTo = this.markers.length; + } + while (iOld < skipTo) { + const next = this.markers[iOld++]; + if (next.toDOM) { + next.destroy(domPos!); + const after = domPos!.nextSibling; + domPos!.remove(); + domPos = after; + } + } + if (!marker) break; + if (marker.toDOM) { + if (matched) domPos = domPos!.nextSibling; + else { + const domRendered = marker.toDOM(view); + this.dom.insertBefore(domRendered, domPos); + // // Get height of domRendered + // setTimeout(() => { + // // @ts-ignore + // const height = domRendered.clientHeight + // // Parse dom.style.marginTop + // const marginTop = parseInt(this.dom.style.marginTop.slice(0, -2)) + // this.dom.style.marginTop = Math.floor(marginTop - height / 2) + "px" + // + // }, 0) + } + } + if (matched) iOld++; + } + this.dom.className = cls; + this.markers = markers; + } + + destroy() { + this.setMarkers(null as any, []); // First argument not used unless creating markers + } +} + +/** + * Checks if two arrays of markers are the same + */ +function sameMarkers(a: readonly GutterMarker[], b: readonly GutterMarker[]): boolean { + if (a.length != b.length) return false; + // @ts-ignore + for (let i = 0; i < a.length; i++) if (!a[i].compare(b[i])) return false; + return true; +} diff --git a/src/main.ts b/src/main.ts index 378910c..3118d61 100644 --- a/src/main.ts +++ b/src/main.ts @@ -17,6 +17,7 @@ import { suggestionMode } from './editor/suggestion-mode/suggestion-mode'; import { nodeCorrecter, bracketMatcher } from './editor/editor-handlers'; import { gutterExtension } from './editor/renderers/criticmarkup-gutter'; +import { commentGutterExtension } from './editor/renderers/comment-gutter'; import { loadPreviewButtons, removePreviewButtons } from './editor/renderers/editor-preview-buttons'; import { loadSuggestButtons, removeSuggestButtons, updateSuggestButtons } from './editor/renderers/editor-suggestion-buttons'; @@ -54,7 +55,11 @@ export default class CommentatorPlugin extends Plugin { this.editorExtensions.push(keybindExtensions); this.editorExtensions.push(treeParser); - this.editorExtensions.push(inlineCommentRenderer); + + if (this.settings.comment_style === "icon" || this.settings.comment_style === "block") + this.editorExtensions.push(inlineCommentRenderer(this.settings)); + if (this.settings.comment_style === "block") + this.editorExtensions.push(commentGutterExtension/*(this.settings)*/); if (this.settings.live_preview) { if (this.settings.alternative_live_preview)