diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000..3a5e197 --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,26 @@ +{ + "env": { + "browser": true, + "es2021": true + }, + "extends": [ + "eslint:recommended", + "plugin:@typescript-eslint/recommended" + ], + "overrides": [ + ], + "parser": "@typescript-eslint/parser", + "parserOptions": { + "ecmaVersion": "latest", + "sourceType": "module" + }, + "plugins": [ + "@typescript-eslint" + ], + "rules": { + "quotes": ["error", "single"], + "semi": ["error", "always"], + "indent": ["error", 4], + "no-multi-space": ["error"] + } +} diff --git a/README.md b/README.md index 43b6b7b..64d7420 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ e.g. ![[my_long_photo|autox200]] ``` -You can now resize both internally and externally embeded images with caption. However, delimeters must be used to distinguis the caption text if it is present. +You can now resize both internally and externally embeded images with caption. However, delimeters must be used to distinguish the caption text if it is present. e.g. If `"` is the caption delimeter. @@ -82,4 +82,5 @@ By turning this option on your captions will be inserted into the document as HT ## Known issues -(none at the moment) ++ Some captions missing. ++ Not compatible with Pandocs for exporting. \ No newline at end of file diff --git a/manifest.json b/manifest.json index 8ce221f..4646fa1 100644 --- a/manifest.json +++ b/manifest.json @@ -1,8 +1,8 @@ { "id": "obsidian-image-caption", "name": "Image Caption", - "version": "0.0.14", - "minAppVersion": "0.15.6", + "version": "0.0.15.alpha", + "minAppVersion": "1.0.0", "description": "Add captions to images.", "author": "Brian Carlsen", "authorUrl": "https://github.com/bicarlsen", diff --git a/package.json b/package.json index 31a143e..4f5db4f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "obsidian-image-caption", - "version": "0.0.14", + "version": "0.0.15.alpha", "description": "Add captions to images in Obsidian.", "main": "main.js", "scripts": { @@ -10,13 +10,18 @@ "keywords": [], "author": "", "license": "MIT", - "devDependencies": { + "dependencies": { + "@codemirror/language": "6.0", + "@codemirror/view": "6.0", "@types/node": "^18.0", "esbuild": "0.14", - "tslib": "2.4", - "typescript": "4.7", "obsidian": "^0.15", - "@codemirror/view": "6.0", - "@codemirror/language": "6.0" + "tslib": "2.4", + "typescript": "4.7" + }, + "devDependencies": { + "@typescript-eslint/eslint-plugin": "^5.36.1", + "@typescript-eslint/parser": "^5.36.1", + "eslint": "^8.23.0" } } diff --git a/src/common.ts b/src/common.ts index 3e0c1a2..7103b44 100644 --- a/src/common.ts +++ b/src/common.ts @@ -5,12 +5,49 @@ import { import ImageCaptionPlugin from './main'; -interface ImageSize { +// ************* +// *** types *** +// ************* + +export interface ImageSize { width: number; height: number; } +export interface ParsedCaption { + text?: string; + size?: ImageSize; +} + + +// ***************** +// *** functions *** +// ***************** + + +/** + * Get the nearest sibling matching the selector. + * + * @param {Element} elm - Element to start searching from. + * @param {string} selector - Selector string. + * @param {number} direction - Direction to search. + * Negative to search previous, otherwise searches next. + * @returns {Element} First element matching selector in the given direction. + */ +export function closestSibling( + elm: Element, + selector: string, + direction?: number +): Element { + const prev = (direction < 0); + let sibling = prev ? elm.previousElementSibling : elm.nextElementSibling; + while ( sibling && ! sibling.matches(selector) ) { + sibling = prev ? sibling.previousElementSibling : sibling.nextElementSibling; + } + + return sibling; +} /** * Parses text to extract the caption and size for the image. @@ -20,7 +57,7 @@ interface ImageSize { * @returns { { caption: string, size?: ImageSize } } * An obect containing the caption text and size. */ -export function parseCaptionText( text: string, delimeter: string[] ): {text: string, size?: ImageSize} | null { +export function parseCaptionText( text: string, delimeter: string[] ): ParsedCaption | null { if ( ! text ) { return null; } @@ -82,7 +119,7 @@ export function parseCaptionText( text: string, delimeter: string[] ): {text: st // size let size = parseSize( remaining_text[ 0 ] ); - if ( ! size ) { + if ( ! size && remaining_text[ 1 ] ) { size = parseSize( remaining_text[ 1 ] ); } @@ -139,6 +176,13 @@ export function addCaption( target.appendChild( caption ); + const style = getComputedStyle(target); + if ( style.getPropertyValue('display') == 'inline' ) { + target.style.display = 'inline-block'; + } + + target.addClass('image-caption-captioned'); + return new MarkdownRenderChild( caption ); } diff --git a/src/index_widget.ts b/src/index_widget.ts new file mode 100644 index 0000000..925d495 --- /dev/null +++ b/src/index_widget.ts @@ -0,0 +1,34 @@ +import { + EditorView, + WidgetType +} from '@codemirror/view'; + +import { ParsedImage } from './state_parser'; + + +export class ImageIndexWidget extends WidgetType { + index: number; + info: ParsedImage; + + constructor(index: number, info: ParsedImage) { + super(); + + this.index = index; + this.info = info; + } + + toDOM(view: EditorView): HTMLElement { + const container = document.createElement('data'); + container.addClass('image-caption-data') + container.setAttribute('data-image-caption-index', this.index.toString()); + + return container; + } + + eq(other: ImageIndexWidget): boolean { + return ( + (other.index === this.index) + && (other.info.embed_type === this.info.embed_type) + ); + } +} diff --git a/src/main.ts b/src/main.ts index a37141a..7cfa9e9 100644 --- a/src/main.ts +++ b/src/main.ts @@ -12,6 +12,7 @@ import { } from './md_processor'; import { processPreviewImageCaption } from './preview_processor'; +import { viewObserver } from './view_observer'; interface ImageCaptionSettings { css: string; @@ -42,13 +43,16 @@ export default class ImageCaptionPlugin extends Plugin { await this.loadSettings(); // register processors for preview mode - const previewProcessor = processPreviewImageCaption( this ); - this.registerEditorExtension( previewProcessor ); + // const previewProcessor = processPreviewImageCaption( this ); + // this.registerEditorExtension( previewProcessor ); + + const viewObs = viewObserver(this); + this.registerEditorExtension(viewObs); // register processors for read mode this.caption_observers = []; - this.registerMarkdownPostProcessor( processInternalImageCaption( this ) ); - this.registerMarkdownPostProcessor( processExternalImageCaption( this ) ); + // this.registerMarkdownPostProcessor( processInternalImageCaption( this ) ); + // this.registerMarkdownPostProcessor( processExternalImageCaption( this ) ); this.addStylesheet(); this.addSettingTab( new ImageCaptionSettingTab( this.app, this ) ); @@ -59,8 +63,8 @@ export default class ImageCaptionPlugin extends Plugin { this.stylesheet.remove(); } - this.clearObservers(); - this.removeCaptions(); + // this.clearObservers(); + // this.removeCaptions(); } async loadSettings() { @@ -90,10 +94,10 @@ export default class ImageCaptionPlugin extends Plugin { } addStylesheet() { - this.stylesheet = document.createElement( 'style' ); - this.stylesheet.setAttribute( 'type', 'text/css' ); + this.stylesheet = document.createElement('style'); + this.stylesheet.setAttribute('type', 'text/css'); this.updateStylesheet(); - document.head.append( this.stylesheet ); + document.head.append(this.stylesheet); } updateStylesheet() { @@ -103,7 +107,7 @@ export default class ImageCaptionPlugin extends Plugin { if ( label ) { // replace all unescaped hashtags with image index const number_pattern = /(^|[^\\])#/g; - label = label.replace( number_pattern, "$1' attr(data-image-caption-index) '" ); // inner quotes used to kill string and insert attr. + between strings breaks it. + label = label.replace( number_pattern, "$1' attr(data-image-caption-fignum) '" ); // inner quotes used to kill string and insert attr. + between strings breaks it. // Replace escaped hashtags with hashtags label = label.replace( '\\#', '#' ); diff --git a/src/preview_processor.ts b/src/preview_processor.ts index 5e69737..3869020 100644 --- a/src/preview_processor.ts +++ b/src/preview_processor.ts @@ -31,6 +31,8 @@ import { updateFigureIndices, } from './common'; +import { StateParser, ParsedImage } from './state_parser'; + // ******************* // *** Definitions *** @@ -48,123 +50,6 @@ type RangeSetBuilderArgs = { from: number, to: number, value: Decoration} const NODE_TYPE_PROP_ID = 11; -// ************************ -// *** Common Functions *** -// ************************ - -/** - * @param {SyntaxNodeRef} node - Node to get properties of. - * @returns {string[]} Array of the node's props. - */ -function node_props(node: SyntaxNodeRef): string[] { - const prop_list = node.type.props[NODE_TYPE_PROP_ID]; - if( prop_list === undefined ) { - return []; - } - - return prop_list.split(' '); -} - -/** - * @param {string} prop - Prop to check for. - * @param {SyntaxNodeRef} node - Node to check. - * @returns {boolean} If the node has the given prop. - */ -function node_has_prop(prop: string, node: SyntaxNodeRef): boolean { - const props = node_props(node); - return props.contains(prop); -} - -/** - * Collect nodes until the given stopping condition is met. - * - * @param {SytnaxNodeRef} start_node - Node to begin at. - * @param {(node: SyntaxNodeRef, nodes?: SyntaxNodeRef[]) => boolean} stop - - * Function used to determine when to stop. - * The function should accept a node, which ws the last added, and can - * optionally accept the entire array of nodes. - * The function should return true when collection should stop, - * otherwise collection will stop at the end of the current tree. - * @returns {SyntaxNodeRef[]} Array of nodes including the start and stop node. - */ -function collect_nodes_until( - start_node: SyntaxNodeRef, - stop: (node: SyntaxNodeRef, nodes?: SyntaxNodeRef[]) => boolean -): SyntaxNodeRef[] { - const nodes = [start_node]; - while( ! stop(nodes.at(-1), nodes) ) { - const next = nodes.at(-1).node.nextSibling; - if ( ! next ) { - break; - } - nodes.push(next); - } - - return nodes; -} - -/** - * @param {SyntaxNodeRef} node - Node to check. - * @return {boolean} Whether the node represents the start of an internally embedded image. - */ -function node_is_embed_start(node: SyntaxNodeRef): boolean { - const prop_id = 'formatting-link-start'; - return node_has_prop(prop_id, node); -} - -/** - * @param {SyntaxNodeRef} node - Node to check. - * @return {boolean} Whether the node represents the end of an internally embedded image. - */ -function node_is_embed_end(node: SyntaxNodeRef): boolean { - const prop_id = 'formatting-link-end'; - return node_has_prop(prop_id, node); -} - -/** - * @param {SyntaxNodeRef} node - Node to check. - * @return {boolean} Whether the node represents the start of an externally embedded image. - */ -function node_is_image_start(node: SyntaxNodeRef): boolean { - const prop_id = 'image-marker'; - return node_has_prop(prop_id, node); -} - -/** - * @param {SyntaxNodeRef} node - Node to check. - * @return {boolean} Whether the node represents the end of an externally embedded image. - */ -function node_is_image_end(node: SyntaxNodeRef, nodes: SyntaxNodeRef[]): boolean { - const prop_id = 'formatting-link-string'; - const is_marker = nodes.map( n => ( node_has_prop(prop_id, n) ? 1 : 0 ) ); - const num_markers = is_marker.reduce( (sum, val ) => ( sum + val ), 0 ); - - return (num_markers == 2); -} - -/** - * @param {SyntaxNodeRef} node - Node to check. - * @return {boolean} Whether the node has alt text. - */ -function node_has_alt_text(node: SyntaxNodeRef): boolean { - const prop_id = 'link-has-alias'; - return node_has_prop(prop_id, node); -} - -/** - * @param {SyntaxNodeRef} node - Node to check. - * @return {boolean} Whether the node is part of the alt text. - */ -function node_is_alt_text(node: SyntaxNodeRef): boolean { - const int_prop_id = 'link-alias'; - const ext_prop_id = 'image-alt-text'; - return ( - node_has_prop(int_prop_id, node) - || node_has_prop(ext_prop_id, node) - ); -} - - // ******************* // *** Decorations *** // ******************* @@ -196,7 +81,7 @@ class ImageCaptionWidget extends WidgetType { const cap = document.createElement('figcaption') cap.innerHTML = this.caption; cap.dataset.imageCaptionIndex = this.index.toString(); - cap.addClass( ImageCaptionPlugin.caption_class ); + cap.addClass(ImageCaptionPlugin.caption_class); return cap; } @@ -231,10 +116,12 @@ function createCaptionedImageMark(): Decoration { */ export function previewImageCaptionParserFactory(plugin: ImageCaptionPlugin) { return class PreviewImageCaptionParser implements PluginValue { + parser: StateParser; decorations: DecorationSet; state_fields: Extension[]; constructor( view: EditorView ) { + this.parser = new StateParser(plugin); this.decorations = this.build_decorations(view.state); } @@ -246,50 +133,57 @@ export function previewImageCaptionParserFactory(plugin: ImageCaptionPlugin) { */ build_decorations(state: EditorState): DecorationSet { const deco_builder = new RangeSetBuilder(); + const images = this.parser.parse(state); + + // imgs.forEach((img) => console.log(img.getAttribute('src'))) + for( let i = 0; i < images.length; i++ ) { + const img = images[i]; + + // // image + // const start = img.nodes.at(0).from; + // const end = img.nodes.at(-1).to; + // const captioned_image_deco = createCaptionedImageMark(); + // deco_builder.add(start, end, captioned_image_deco); + + // caption + const pos = img.nodes.at(-1).to; + const caption_marker = Decoration.widget({ + widget: new ImageCaptionWidget(img.caption, i) + }); + + deco_builder.add(pos, pos, caption_marker); + } + + const live_obs = new MutationObserver( + ( mutations: MutationRecord[], observer: MutationObserver ) => { + mutations.forEach((rec: MutationRecord) => { + if ( rec.type === 'childList' ) { + // console.log(mutations) + const imgs = rec.target.querySelectorAll('img:not(.cm-widgetBuffer)'); + // console.log(imgs) + + if ( rec.addedNodes.length ) { + + } - let index = 0; - const tree = syntaxTree(state) - tree.iterate({ - enter: (node: SyntaxNodeRef): boolean => { - if ( node_is_embed_start(node) ) { - const nodes = collect_nodes_until(node, node_is_embed_end); - const has_alt_text = nodes.some(node_has_alt_text); - if ( ! has_alt_text ) { - return false; + if ( rec.removedNodes.length ) { + + } } - // image - const start = nodes.at(0).from; - const end = nodes.at(-1).to; - const captioned_image_deco = createCaptionedImageMark(); - deco_builder.add(start, start, captioned_image_deco); - - // caption - const {from, to, value} = this.parse_internal_embed( - nodes, index, state - ); - - deco_builder.add(from, to, value); - } - else if ( node_is_image_start(node) ) { - const nodes = collect_nodes_until(node, node_is_image_end); - - // caption - const {from, to, value} = this.parse_external_image( - nodes, index, state - ); - - deco_builder.add(from, to, value); - } - else { - // do nothing, recurse on children - return true; - } - - index += 1; - return false; // prevent recursing on children + }); } - }); + ); + + const live_preview = document.querySelector('.markdown-source-view.is-live-preview'); + const reader_view = document.querySelector('.markdown-reading-view'); + // live_obs.observe(live_preview, { + // subtree: true, + // childList: true, + // attributeFilter: [ 'class' ] + // }); + + return deco_builder.finish(); } @@ -304,102 +198,32 @@ export function previewImageCaptionParserFactory(plugin: ImageCaptionPlugin) { this.decorations = decos; // fig sizes - const imgs = document.querySelectorAll('img'); - imgs.forEach( (img: HTMLElement) => { - const alt_text = img.getAttribute( 'alt' ); - const delimeter = plugin.settings.delimeter; - const parsed = parseCaptionText( alt_text, delimeter ); - - if( !parsed ) { - return; - } - - const size = parsed.size; - const caption = parsed.text; - - if( !size ) { - return; - } - - const w = size.width.toString(); - const h = size.height.toString(); - img.setAttribute( 'width', w ); - img.setAttribute( 'height', h ); - }); - + // const imgs = document.querySelectorAll('img'); + // imgs.forEach( (img: HTMLElement) => { + // const alt_text = img.getAttribute( 'alt' ); + // const delimeter = plugin.settings.delimeter; + // const parsed = parseCaptionText( alt_text, delimeter ); + + // if( !parsed ) { + // return; + // } + + // const size = parsed.size; + // const caption = parsed.text; + + // if( !size ) { + // return; + // } + + // const w = size.width.toString(); + // const h = size.height.toString(); + // img.setAttribute( 'width', w ); + // img.setAttribute( 'height', h ); + // }); } destroy() { } - - /** - * Parse internally embeded images. - * - * @param {SyntaxNodeRef[]} nodes - List of nodes representing the embeded image. - * @param {number} index - Figure index. - * @param {EditorState} state - Current editor state. - * @returns {RangeSetBuilderArgs} Arguments used for building the decoration. - */ - parse_internal_embed( - nodes: SyntaxNodeRef[], - index: number, - state: EditorState - ): RangeSetBuilderArgs { - const alt_nodes = nodes.filter(node_is_alt_text); - const alt_text = state.sliceDoc( alt_nodes.at(0).from, alt_nodes.at(-1).to ); - - const delimeter = plugin.settings.delimeter; - const parsed = parseCaptionText( alt_text, delimeter ); - const size = parsed.size; - const caption = parsed.text; - - const deco = Decoration.widget({ - widget: new ImageCaptionWidget(caption, index) - }); - - const im_pos = nodes.at(-1).to; - const cap_pos = im_pos + 1; - - return { - from: cap_pos, - to: cap_pos, - value: deco - }; - } - - /** - * Parse externally embeded images. - * - * @param {SyntaxNodeRef[]} nodes - List of nodes representing the embeded image. - * @param {number} index - Figure index. - * @param {EditorState} state - Current editor state. - * @returns {RangeSetBuilderArgs} Arguments used for building the decoration. - */ - parse_external_image( - nodes: SyntaxNodeRef[], - index: number, - state: EditorState - ): RangeSetBuilderArgs{ - const alt_nodes = nodes.filter(node_is_alt_text); - const alt_text = state.sliceDoc( alt_nodes.at(0).from, alt_nodes.at(-1).to ); - - const delimeter = plugin.settings.delimeter; - const parsed = parseCaptionText( alt_text, delimeter ); - const size = parsed.size; - const caption = parsed.text; - - const deco = Decoration.widget({ - widget: new ImageCaptionWidget(caption, index) - }); - - const im_pos = nodes.at(-1).to - const cap_pos = im_pos + 1; - return { - from: cap_pos, - to: cap_pos, - value: deco - }; - } } } diff --git a/src/md_processor.ts b/src/reader_observer.ts similarity index 91% rename from src/md_processor.ts rename to src/reader_observer.ts index 7a7f27f..7d5668c 100644 --- a/src/md_processor.ts +++ b/src/reader_observer.ts @@ -8,7 +8,7 @@ import { parseCaptionText, addCaption, setSize, - updateFigureIndices, + updateFigureIndices } from './common'; /** @@ -88,7 +88,7 @@ export function externalCaptionObserver( for ( const mutation of mutations ) { const captions = [ ...mutation.addedNodes ].filter( ( elm: HTMLElement ) => { - return elm.matches( ImageCaptionPlugin.caption_selector ) + return elm.matches(ImageCaptionPlugin.caption_selector); } ); @@ -98,9 +98,9 @@ export function externalCaptionObserver( } } - if ( update ) { + if (update) { updateFigureIndices(); - plugin.removeObserver( observer ); + plugin.removeObserver(observer); } } ); } @@ -121,7 +121,8 @@ export function processInternalImageCaption( el: HTMLElement, ctx: MarkdownPostProcessorContext ): void { - el.querySelectorAll( 'span.internal-embed' ).forEach( + console.log(el) + el.querySelectorAll( 'span.internal-embed.image-embed' ).forEach( ( container: HTMLElement ) => { // must listen for class changes because images // may be loaded after this run @@ -164,7 +165,7 @@ export function processExternalImageCaption( } const img = elm; - if ( img.closest( `.${container_css_class}` ) ) { + if (img.closest(`.${container_css_class}`)) { // caption already added return; } diff --git a/src/state_parser.ts b/src/state_parser.ts new file mode 100644 index 0000000..e79cada --- /dev/null +++ b/src/state_parser.ts @@ -0,0 +1,350 @@ +import { + SyntaxNodeRef +} from '@lezer/common'; + +import { + Range, + RangeSetBuilder, + EditorState, + StateField, + Extension, + Transaction +} from '@codemirror/state'; + +import { + PluginValue, + ViewPlugin, + EditorView, + ViewUpdate, + WidgetType, + Decoration, + DecorationSet +} from '@codemirror/view'; + +import { syntaxTree } from '@codemirror/language'; + +import ImageCaptionPlugin from './main'; +import { + ImageSize, + ParsedCaption, + parseCaptionText, + addCaption, + setSize, + updateFigureIndices, +} from './common'; + + +// ******************* +// *** Definitions *** +// ******************* + +/** + * Holds information about a parsed image. + * + * + nodes: List of nodes representing the entire image. + * + src: Image source URI + * + caption: Image caption, if it exists + * + size: A size object of {width: number, height: number} of the desired dimensions, + * if it exists. + * + embed_type: String indicating the type of embed. + * Values are ['internal', 'external'] + */ +export interface ParsedImage { + nodes: SyntaxNodeRef[]; + src: string; + caption?: string; + size?: ImageSize; + embed_type: EmbedType; +} + +/** + * Type of embed. + */ +export enum EmbedType { + Internal = 'internal', + External = 'external', +} + +/** + * Id of the `node.type.props` storing relevant information. + */ +const NODE_TYPE_PROP_ID = 11; + + +// ************************ +// *** Common Functions *** +// ************************ + +/** + * @param {SyntaxNodeRef[]} nodes - List of nodes to extract text between. + * @param {EditorState} state - Editor state containing the DOM. + * @returns {string} Text between the first and last nodes. + */ +function nodes_text(nodes: SyntaxNodeRef[], state: EditorState): string { + return state.doc.slice( + nodes.at(0).from, + nodes.at(-1).to + ).toString(); +} + +/** + * @param {SyntaxNodeRef} node - Node to get properties of. + * @returns {string[]} Array of the node's props. + */ +function node_props(node: SyntaxNodeRef): string[] { + const props = node.type ? node.type.props : node.props; + const prop_list = props[NODE_TYPE_PROP_ID]; + if( prop_list === undefined ) { + return []; + } + + return prop_list.split(' '); +} + +/** + * @param {string} prop - Prop to check for. + * @param {SyntaxNodeRef} node - Node to check. + * @returns {boolean} If the node has the given prop. + */ +function node_has_prop(prop: string, node: SyntaxNodeRef): boolean { + const props = node_props(node); + return props.contains(prop); +} + +/** + * Collect nodes until the given stopping condition is met. + * + * @param {SytnaxNodeRef} start_node - Node to begin at. + * @param {(node: SyntaxNodeRef, nodes?: SyntaxNodeRef[]) => boolean} stop - + * Function used to determine when to stop. + * The function should accept a node, which ws the last added, and can + * optionally accept the entire array of nodes. + * The function should return true when collection should stop, + * otherwise collection will stop at the end of the current tree. + * @returns {SyntaxNodeRef[]} Array of nodes including the start and stop node. + */ +function collect_nodes_until( + start_node: SyntaxNodeRef, + stop: (node: SyntaxNodeRef, nodes?: SyntaxNodeRef[]) => boolean +): SyntaxNodeRef[] { + const nodes = [start_node]; + while( ! stop(nodes.at(-1), nodes) ) { + const next = nodes.at(-1).node.nextSibling; + if ( ! next ) { + break; + } + nodes.push(next); + } + + return nodes; +} + +/** + * @param {SyntaxNodeRef} node - Node to check. + * @return {boolean} Whether the node represents the start of an internally embedded image. + */ +function node_is_embed_start(node: SyntaxNodeRef): boolean { + const prop_id = 'formatting-link-start'; + return node_has_prop(prop_id, node); +} + +/** + * @param {SyntaxNodeRef} node - Node to check. + * @return {boolean} Whether the node represents the end of an internally embedded image. + */ +function node_is_embed_end(node: SyntaxNodeRef): boolean { + const prop_id = 'formatting-link-end'; + return node_has_prop(prop_id, node); +} + +/** + * @param {SyntaxNodeRef} node - Node to check. + * @return {boolean} Whether the node represents the start of an externally embedded image. + */ +function node_is_image_start(node: SyntaxNodeRef): boolean { + const prop_id = 'image-marker'; + return node_has_prop(prop_id, node); +} + +/** + * @param {SyntaxNodeRef} node - Node to check. + * @return {boolean} Whether the node represents the end of an externally embedded image. + */ +function node_is_image_end(node: SyntaxNodeRef, nodes: SyntaxNodeRef[]): boolean { + const prop_id = 'formatting-link-string'; + const is_marker = nodes.map( n => ( node_has_prop(prop_id, n) ? 1 : 0 ) ); + const num_markers = is_marker.reduce( (sum, val ) => ( sum + val ), 0 ); + + return (num_markers == 2); +} + +/** + * @param {SyntaxNodeRef} node - Node to check. + * @return {boolean} Whether the node has alt text. + */ +function node_has_alt_text(node: SyntaxNodeRef): boolean { + const prop_id = 'link-has-alias'; + return node_has_prop(prop_id, node); +} + +/** + * @param {SyntaxNodeRef} node - Node to check. + * @return {boolean} Whether the node is part of the alt text. + */ +function node_is_alt_text(node: SyntaxNodeRef): boolean { + const int_prop_id = 'link-alias'; + const ext_prop_id = 'image-alt-text'; + return ( + node_has_prop(int_prop_id, node) + || node_has_prop(ext_prop_id, node) + ); +} + +/** + * @param {SyntaxNodeRef} node - Node to check. + * @return {boolean} Whether the node contains the source of an internally embedded image. + */ +function node_is_embed_src(node: SyntaxNodeRef): boolean { + const prop_id = 'hmd-internal-link'; + return node_has_prop(prop_id, node); +} + +/** + * @param {SyntaxNodeRef} node - Node to check. + * @return {boolean} Whether the node contains the source of an externally embeded image. + */ +function node_is_image_src(node: SyntaxNodeRef): boolean { + const prop_id = 'url'; + return node_has_prop(prop_id, node); +} + + +// ************** +// *** Parser *** +// ************** + + +export class StateParser { + plugin: ImageCaptionPlugin; + + /** + * @param {ImageCaptionPlugin} plugin - Plugin instance to use for parsing settings. + */ + constructor(plugin: ImageCaptionPlugin) { + this.plugin = plugin; + } + + /** + * Parses an editor state returning a list of parsed images. + * + * @param {EditorState} state: Editor state to parse. + * @returns {ParsedImage[]} List of parsed images. + */ + parse(state: EditorState): ParsedImage[] { + const tree = syntaxTree(state); + const images: ParsedImage[] = []; + let embed_type: EmbedType; + + tree.iterate({ + enter: (node: SyntaxNodeRef): boolean => { + let nodes; + if ( node_is_embed_start(node) ) { + console.debug(node); + nodes = collect_nodes_until(node, node_is_embed_end); + embed_type = EmbedType.Internal; + } + else if ( node_is_image_start(node) ) { + nodes = collect_nodes_until(node, node_is_image_end); + embed_type = EmbedType.External; + } + else { + // not an image, do nothing, recurse on children + return true; + } + + const src = this.parse_nodes_src( nodes, state ); + let caption_info = this.parse_nodes_caption( nodes, state ); + if ( ! caption_info ) { + caption_info = { + text: undefined, + size: undefined + }; + } + + const image: ParsedImage = { + nodes, + src, + caption: caption_info.text, + size: caption_info.size, + embed_type + }; + + images.push(image); + return false; // prevent recursing on children + } + }); + + return images; + } + + /** + * Parse caption from nodes. + * + * @param {SyntaxNodeRef[]} nodes - List of nodes representing the image. + * @param {EditorState} state - Current editor state. + * @returns {string} Image's source URI. + * @throws {Error} If the nodes' source could not be determined. + */ + parse_nodes_src( + nodes: SyntaxNodeRef[], + state: EditorState + ): string { + let src_nodes; + src_nodes = nodes.filter(node_is_embed_src); + if ( src_nodes.length ) { + // internal embed + const src_text = nodes_text(src_nodes, state); + const src_parts = src_text.split('|').map( + (pt: string) => { + return pt.trim(); + } + ); + + return src_parts[0]; + } + + src_nodes = nodes.filter(node_is_image_src); + if ( !src_nodes.length ) { + throw Error('Could not find source for nodes.'); + } + + // external embed + let src_text = nodes_text(src_nodes, state).trim(); + + // strip beginning and end + src_text = src_text.slice(1, -1); + return src_text; + } + + + /** + * Parse caption from nodes. + * + * @param {SyntaxNodeRef[]} nodes - List of nodes representing the image. + * @param {EditorState} state - Current editor state. + * @returns {ParsedCaption | null} Parsed caption information or null if none exists. + */ + parse_nodes_caption( + nodes: SyntaxNodeRef[], + state: EditorState + ): ParsedCaption | null { + const alt_nodes = nodes.filter(node_is_alt_text); + if ( ! alt_nodes.length ) { + return null; + } + + const alt_text = state.sliceDoc( alt_nodes.at(0).from, alt_nodes.at(-1).to ); + const delimeter = this.plugin.settings.delimeter; + return parseCaptionText( alt_text, delimeter ); + } +} diff --git a/src/view_observer.ts b/src/view_observer.ts new file mode 100644 index 0000000..1ca9d52 --- /dev/null +++ b/src/view_observer.ts @@ -0,0 +1,229 @@ +import { + SyntaxNodeRef +} from '@lezer/common'; + +import { + Range, + RangeSetBuilder, + EditorState, + Extension, + Transaction +} from '@codemirror/state'; + +import { + PluginValue, + ViewPlugin, + EditorView, + ViewUpdate, + WidgetType, + Decoration, + DecorationSet +} from '@codemirror/view'; + +import ImageCaptionPlugin from './main'; +import { closestSibling, addCaption, setSize } from './common'; +import { StateParser, ParsedImage, EmbedType } from './state_parser'; +import { ImageIndexWidget } from './index_widget'; + +// ******************* +// *** View Plugin *** +// ******************* + +/** + * @todo Should proabably be structured in a better way, perhaps by using a StateField? + * + * Factory function allowing access to the plugin. + * + * @returns {ImageCaptionParser} Parser class to contruct a View Plugin + * for CodeMirror using ViewPlugin#fromClass. + */ +export function ViewObserverFactory(plugin: ImageCaptionPlugin) { + return class ImageCaptionParser implements PluginValue { + parser: StateParser; + image_info: ParsedImage[]; + observers: MutationObserver[]; + decorations: DecorationSet; + + constructor( view: EditorView ) { + this.parser = new StateParser(plugin); + this.observers = []; + + this.image_info = this.parser.parse(view.state); + this.decorations = this.mark_images(this.image_info); + this.register_observers(view, this.image_info); + } + + /** + * Update captions when the document state has changed. + */ + update(update: ViewUpdate) { + this.image_info = this.parser.parse(update.state); + if (update.docChanged) { + this.decorations = this.mark_images(this.image_info); + this.register_observers(update.view, this.image_info); + } + else if (update.viewportChanged) { + this.register_observers(update.view, this.image_info); + } + } + + destroy() { + this.clear_observers(); + } + + /** + * Creates image marker decorations. + * + * @param {ParsedImage[]} images - List of parsed images. + * @returns {DecorationSet} Decoration to mark images. + */ + mark_images(images: ParsedImage[]): DecorationSet { + const decos = []; + for (let i = 0; i < images.length; i++) { + const img = images[i]; + const dec = Decoration.widget( { + widget: new ImageIndexWidget(i, img), + side: 9999 // place marker as last decoration to ensure image is above, marker must be placed after image for control. + } ); + + const pos = img.nodes.at(-1).to; + decos.push(dec.range(pos)); + } + + return Decoration.set(decos); + } + + /** + * Register observers for each view. + * + * @param {EditorView} view - CodeMirror editor view. + * @param {ParsedImage[]} images - Parsed images from the document. + */ + register_observers(view: EditorView, images: ParsedImage[]) { + const {preview, source, reading} = this.parse_views(view.root); + if (preview) { + this.register_preview_observers(preview, images); + } + if (source) { + this.register_source_observers(source, images); + } + if (reading) { + this.register_reading_observers(reading, images); + } + } + + /** + * Parses the root document into its view components. + * + * @param {Element} root - Root element of the document. + * @returns {{preview: Element, source: Element, reading: Element}} + * Root element of each view. + */ + parse_views( + root: Element + ): {preview: Element, source: Element, reading: Element} { + const preview = root.querySelector('.markdown-source-view.is-live-preview'); + const source = root.querySelector('.markdown-source-view:not(.is-live-preview'); + const reading = root.querySelector('.markdown-reading-view'); + + return {preview, source, reading}; + } + + /** + * Register observers for the preview view. + * + * @param {Element} root - Root of the preview view. + * @param {ParsedImages[]} images - Parsed images. + */ + register_preview_observers(root: Element, images: ParsedImage[]) { + // clear previous captions + const prev_caps = root.querySelectorAll( + `.${ImageCaptionPlugin.caption_class}` + ); + prev_caps.forEach( cap => cap.remove() ); + + const markers = root.querySelectorAll('.image-caption-data'); + markers.forEach( marker => { + const img_index = parseInt(marker.getAttribute('data-image-caption-index')); + const info = this.image_info[img_index]; + const embed_type = info.embed_type; + + if (embed_type === EmbedType.Internal) { + const img_wrap = closestSibling( + marker, + '.internal-embed.image-embed.is-loaded', + -1 + ); + + if ( ! img_wrap ) { + console.debug(`image container not found for mark ${img_index}`); + return; + } + + if ( info.caption ) { + const cap = addCaption(img_wrap, info.caption); + // const fig_num = img_index.toString() + const fig_num = img_index + 1; + cap.containerEl.setAttribute('data-image-caption-fignum', fig_num.toString()); + } + + if ( info.size ) { + setSize( img_wrap, info.size ); + } + } + else if (embed_type === EmbedType.External) { + + } + else { + throw new Error(`Invalid embed type ${embed_type}.`); + } + } ); + + + } + + /** + * Register observers for the source view. + * + * @param {Element} root - Root of the source view. + * @param {ParsedImages[]} images - Parsed images. + */ + register_source_observers(root: Element, images: ParsedImage[]) { + + } + + /** + * Register observers for the reading view. + * + * @param {Element} root - Root of the reading view. + * @param {ParsedImages[]} images - Parsed images. + */ + register_reading_observers(root: Element, images: ParsedImage[]) { + + } + + /** + * Removes all observers. + */ + clear_observers() { + + } + }; +} + + +export function viewObserver( + plugin: ImageCaptionPlugin +): Extension { + + const view_plug = ViewPlugin.fromClass( + ViewObserverFactory(plugin), + { + decorations: v => v.decorations + } + ); + + return [ + view_plug + ]; +} diff --git a/versions.json b/versions.json index 945bd5c..6d8cc00 100644 --- a/versions.json +++ b/versions.json @@ -1,4 +1,5 @@ { + "0.0.15.alpha": "1.0.0", "0.0.14": "0.15.6", "0.0.13": "0.14.2", "0.0.12": "0.13.23",