diff --git a/CHANGELOG.md b/CHANGELOG.md index fcf6925..bfd6407 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,11 @@ ## next - Added `navbuttons.uploadFromClipboard` button -- Changed `struct` view to avoid auto expading numeric arrays +- Added `marks` option for `source` view, which allows injecting visual or text marks at specific points in the source text +- Renamed the `refs` option in the `source` view to `ranges` +- Updated the `type` field of entries in the `ranges` option (previously `refs`) to be optional. If the `href` field is specified, the `type` value defaults to `link`; otherwise, it defaults to `span` instead of `spotlight`. The `spotlight` type is now replaced by `span` - Fixed default copy text button action in `source` view +- Changed `struct` view to avoid auto expading numeric arrays ## 1.0.0-beta.92 (18-12-2024) diff --git a/src/views/text/source.css b/src/views/text/source.css index 75ed999..d1b9c93 100644 --- a/src/views/text/source.css +++ b/src/views/text/source.css @@ -126,44 +126,133 @@ color: var(--discovery-fmt-color); } -.view-source__source .spotlight { +.view-source__source .spotlight, +.view-source__source .mark { + --bg-color: light-dark(#f5e69a, #515143); + --color: light-dark(#948634, #bdb06a); + background: #f5e69a; - background: light-dark(#f5e69a, #515143); + background: var(--bg-color); background-image: linear-gradient(to right, light-dark(#fafafa80, transparent), light-dark(#fafafa80, transparent) ); - color: light-dark(#948634, #bdb06a); + color: var(--color); padding: 1px 1px 2px; margin: 0 -1px; } -.view-source__source .spotlight.error { - background-color: light-dark(#ffc9c9, #603c3c); - color: light-dark(#bb6665, #ed9e9d); +.view-source__source .mark { + margin-left: 0px; + margin-right: 2px; + padding: 1.5px .35em 2.5px; + font-family: var(--discovery-font-family); + font-size: 83%; + line-height: 1; + border: .5px solid currentColor; + border-color: color-mix(in srgb, currentColor 65%, var(--bg-color)); + border-radius: 4px; + background-color: light-dark(#e4e4e4, #2b2b2b); + background-clip: padding-box; + text-decoration-thickness: 1px; +} +.view-source__source .mark[data-kind=none] { + color: var(--discovery-color); + border-color: transparent; + background-clip: border-box; +} +.view-source__source .mark[data-kind=dot]::before { + --dot-color: var(--color); + content: ''; + display: inline-block; + vertical-align: middle; + padding: 4px; + margin: -2px 4px 0 1px; + font-size: 0px; + border-radius: 4px; + background-color: color-mix(in srgb, var(--dot-color) 85%, var(--discovery-background-color)); +} +.view-source__source .mark[data-kind=dot]:empty { + border: none; + background-color: transparent; + padding-left: 1px; + padding-right: 1px; +} +.view-source__source .mark[data-kind=dot]:empty::before { + margin-right: 1px; +} +.view-source__source .mark[data-kind=self] { + --color: light-dark(#4c934c, #6eae6e); } -.view-source__source .spotlight.def { - background-color: light-dark(#d3e7fb, #3a5066); - color: light-dark(#668fb8, #8cbae7); +.view-source__source .mark[data-kind=nested] { + --color: light-dark(#96944d, #aead6e); } -.view-source__source .spotlight.ref { - background-color: light-dark(#cee99b, #415220); - color: light-dark(#7a9a3b, #95ca2c); +.view-source__source .mark[data-kind=total] { + --color: light-dark(#9580bf, #aa92da); } -.view-source__source .spotlight.global-ref { - background-color: light-dark(#ffd8a3, #61492a); - color: light-dark(#9a7f59, #d8b381); +.view-source__source .mark[data-prefix]:not([data-kind=dot])::before { + content: attr(data-prefix); + display: inline-block; + color: color-mix(in srgb, currentColor 50%, var(--bg-color)); + padding: 0 3px 0 1px; +} +.view-source__source .mark[data-postfix]:not([data-kind=dot])::after { + content: attr(data-postfix); + display: inline-block; + color: color-mix(in srgb, currentColor 50%, var(--bg-color)); } -.view-source__source a.spotlight { + +.view-source__source .mark.inactive { + --color: #888; +} +.view-source__source .spotlight.error, +.view-source__source .mark.error { + --bg-color: light-dark(#ffc9c9, #603c3c); + --color: light-dark(#bb6665, #ed9e9d); +} +.view-source__source .spotlight.def, +.view-source__source .mark.def { + --bg-color: light-dark(#d3e7fb, #3a5066); + --color: light-dark(#668fb8, #8cbae7); +} +.view-source__source .spotlight.ref, +.view-source__source .mark.ref { + --bg-color: light-dark(#cee99b, #415220); + --color: light-dark(#7a9a3b, #95ca2c); +} +.view-source__source .spotlight.global-ref, +.view-source__source .mark.global-ref { + --bg-color: light-dark(#ffd8a3, #61492a); + --color: light-dark(#9a7f59, #d8b381); +} + +.view-source__source a.spotlight, +.view-source__source a.mark { position: relative; text-decoration-color: color-mix(in srgb, currentcolor 45%, transparent); + text-underline-offset: 1.5px; } -.view-source__source a.spotlight:hover { - text-decoration-color: color-mix(in srgb, currentcolor 70%, transparent); +.view-source__source a.spotlight:hover, +.view-source__source a.mark:hover { + text-decoration-color: color-mix(in srgb, currentcolor 75%, transparent); background-image: linear-gradient(to right, light-dark(transparent, #35353580), light-dark(transparent, #35353580) ); } +.view-source__source a.mark:hover, +.view-source__source .mark.discovery-view-has-tooltip:hover { + border-color: currentColor; +} +.view-source__source a.mark[data-kind=dot]:empty::before, +.view-source__source .mark.discovery-view-has-tooltip[data-kind=dot]:empty::before { + outline: 1px solid color-mix(in srgb, var(--dot-color) 45%, transparent); + outline-offset: 1px; +} +.view-source__source a.mark[data-kind=dot]:hover::before, +.view-source__source .mark.discovery-view-has-tooltip[data-kind=dot]:hover::before { + outline-color: var(--dot-color); +} + .view-source__source .spotlight-ignore { background: #ddd; background-image: repeating-linear-gradient(-45deg, diff --git a/src/views/text/source.js b/src/views/text/source.js index 0f09ef0..2f9c5cc 100644 --- a/src/views/text/source.js +++ b/src/views/text/source.js @@ -5,6 +5,7 @@ import CodeMirror from 'codemirror'; import 'codemirror/mode/javascript/javascript.js'; import 'codemirror/mode/css/css.js'; import 'codemirror/mode/xml/xml.js'; +import { escapeHtml } from '../../core/utils/html.js'; import { createElement } from '../../core/utils/dom.js'; import { copyText } from '../../core/utils/copy-text.js'; import usage from './source.usage.js'; @@ -56,19 +57,19 @@ function codeMirrorHighlight(modespec, host) { function classNames(options, defaultClassNames) { const customClassName = options && options.className; - const classNames = [ - defaultClassNames, - Array.isArray(customClassName) - ? customClassName.join(' ') - : (typeof customClassName === 'string' ? customClassName : false) - ].filter(Boolean).join(' '); + const resolvedClassName = Array.isArray(customClassName) + ? customClassName.join(' ') + : (typeof customClassName === 'string' ? customClassName : false); + const classNames = defaultClassNames && resolvedClassName + ? [defaultClassNames, resolvedClassName].join(' ') + : defaultClassNames || resolvedClassName || ''; return classNames ? ` class="${classNames}"` : ''; } -function refAttrs(data) { +function refAttrs(data, defaultClassName = 'spotlight') { return `${ - classNames(data, 'spotlight') + classNames(data, defaultClassName) }${ data.marker ? ` data-marker="${data.marker}"` : '' }${ @@ -76,32 +77,68 @@ function refAttrs(data) { }`; } +function markAttrs(data, defaultClassName = 'mark') { + const prefix = data.prefix ?? (['self', 'nested', 'total'].includes(data.kind) ? data.kind[0].toUpperCase() : undefined); + const postfix = data.postfix ?? undefined; + + return refAttrs(data, defaultClassName) + ` data-render-id=${data.renderId}${ + data.kind ? ` data-kind="${data.kind}"` : '' + }${ + prefix !== undefined ? ` data-prefix="${escapeHtml(prefix)}"` : '' + }${ + postfix !== undefined ? ` data-postfix="${escapeHtml(postfix)}"` : '' + }`; +} + const refsPrinter = { html: { open({ data }) { switch (data.type) { case 'link': - return ``; + return ``; + case 'span': case 'spotlight': - return ``; + return ``; } }, close({ data }) { switch (data.type) { case 'link': return ''; + case 'span': case 'spotlight': return ''; } } } }; +const marksPrinter = { + html: { + open({ data }) { + switch (data.type) { + case 'link': + return ``; + case 'span': + return ``; + } + }, + close({ data }) { + switch (data.type) { + case 'link': + return ''; + case 'span': + return ''; + } + } + } +}; const props = `is not array? | { source: #.props has no 'source' ? is string ?: content is string ? content : source, syntax, lineNum is function ?: is not undefined ? bool() : true, - refs is array ?: null, + ranges: #.props.refs or refs | is array ?: undefined, + marks is array ?: null, maxSourceSizeToHighlight is number ?: 250 * 1024, // 250Kb actionButtons: undefined, actionCopySource is undefined ? true, @@ -114,13 +151,16 @@ export default function(host) { const preludeEl = el.appendChild(createElement('div', 'view-source__prelude')); const contentEl = el.appendChild(createElement('div', 'view-source__content')); const postludeEl = el.appendChild(createElement('div', 'view-source__postlude')); - const refsTooltips = new Map(); + const viewTooltips = new Map(); + const markContentRenders = new Map(); + const markRenders = []; const decorators = []; const { source, syntax, lineNum = true, - refs, + ranges, + marks, maxSourceSizeToHighlight, actionButtons, actionCopySource, @@ -134,7 +174,8 @@ export default function(host) { source, syntax, lineNum, - refs, + ranges, + marks, maxSourceSizeToHighlight } }; @@ -157,27 +198,91 @@ export default function(host) { }]); } - if (Array.isArray(refs)) { + if (Array.isArray(ranges)) { decorators.push([ - (_, createRange) => refs.forEach(ref => { - if (ref.range) { - let tooltipId = undefined; - - if (ref.tooltip) { - refsTooltips.set(tooltipId = refsTooltips.size, ref); - } - - createRange( - ref.range[0], - ref.range[1], - { type: 'spotlight', ...ref, tooltipId } - ); + (_, createRange) => ranges.forEach(ref => { + if (!ref || typeof ref !== 'object') { + host.logger.warn('Bad value for an entry in "source" view props.ranges, must be an object', { props, entry: ref }); + return; + } + + const refType = ref.type + ? (ref.type === 'spotlight' ? 'span' : ref.type) + : (ref.href ? 'link' : 'span'); + const refRange = ref.range; + let tooltipId = undefined; + + if (!['link', 'span'].includes(refType)) { + host.logger.warn(`Bad type "${refType}" of an entry in "source" view props.ranges`, { props, ref }); + return; + } + + if (!refRange) { + host.logger.warn('Missed range for an entry in "source" view props.ranges', { props, entry: ref }); + return; } + + if (ref.tooltip) { + viewTooltips.set(tooltipId = viewTooltips.size, ref); + } + + createRange( + ref.range[0], + ref.range[1], + { ...ref, type: refType, tooltipId } + ); }), refsPrinter ]); } + if (Array.isArray(marks)) { + decorators.push([ + (_, createRange) => marks.forEach(mark => { + if (!mark || typeof mark !== 'object') { + host.logger.warn('Bad value for an entry in "source" view props.mark, must be an object', { props, mark }); + return; + } + + const markKind = !mark.content + ? 'dot' + : !mark.kind || ['dot', 'self', 'nested', 'total', 'none'].includes(mark.kind) + ? mark.kind || 'span' + : undefined; + const markOffset = typeof mark.offset === 'number' && isFinite(mark.offset) + ? Math.max(0, Math.round(mark.offset)) + : undefined; + let tooltipId = undefined; + + if (typeof markKind !== 'string') { + host.logger.warn('Bad "kind" value for an entry in "source" view props.marks', { props, mark }); + return; + } + + if (typeof markOffset !== 'number') { + host.logger.warn('Bad "offset" value for an entry in "source" view props.marks', { props, mark }); + return; + } + + if (mark.tooltip) { + viewTooltips.set(tooltipId = viewTooltips.size, mark); + } + + const markConfig = { + ...mark, + type: typeof mark.href === 'string' ? 'link' : 'span', + kind: markKind, + renderId: markContentRenders.size, + tooltipId + }; + + markContentRenders.set(markConfig.renderId, markConfig); + createRange(markOffset, markOffset, markConfig); + }), + marksPrinter + ]); + } + const lineOffset = typeof lineNum === 'function' ? lineNum : idx => idx + 1; const lines = lineNum ? '
' + @@ -194,6 +299,16 @@ export default function(host) { hitext(decorators, 'html')(source) }
`; + if (markContentRenders.size) { + for (const markEl of contentEl.querySelectorAll('.mark[data-render-id]')) { + const markConfig = markContentRenders.get(Number(markEl.dataset.renderId)); + + if (markConfig.content) { + markRenders.push(this.render(markEl, markConfig.content, markConfig, nestedViewRenderContext)); + } + } + } + // action buttons const actionButtonsEl = createElement('div', 'view-source__action-buttons'); const actionCopyFn = actionCopySource !== true @@ -221,7 +336,7 @@ export default function(host) { // tooltips for (const refEl of contentEl.querySelectorAll(':scope [data-tooltip-id]')) { - const ref = refsTooltips.get(Number(refEl.dataset.tooltipId)); + const ref = viewTooltips.get(Number(refEl.dataset.tooltipId)); delete refEl.dataset.tooltipId; this.tooltip(refEl, ref.tooltip, ref, context); @@ -234,6 +349,10 @@ export default function(host) { if (postlude) { await host.view.render(postludeEl, postlude, nestedViewRenderData, nestedViewRenderContext); } + + if (markRenders.length) { + await Promise.all(markRenders); + } }, { props, usage, diff --git a/src/views/text/source.usage.js b/src/views/text/source.usage.js index 3a9c193..afb601b 100644 --- a/src/views/text/source.usage.js +++ b/src/views/text/source.usage.js @@ -88,6 +88,17 @@ export default { ], source: false }, + { + title: 'Max content size for syntax highlight', + highlightProps: ['maxSourceSizeToHighlight'], + beforeDemo: ['md:"By default a syntax highlighing is not appling to a source longer than 250Kb. Option `maxSourceSizeToHighlight` is using to change max size of source to be syntax highlighted."'], + demo: { + view: 'source', + source: codeExample, + syntax: 'js', + maxSourceSizeToHighlight: 4 + } + }, { title: 'Custom line numbers', highlightProps: ['lineNum'], @@ -174,38 +185,108 @@ export default { }, { title: 'Highlight ranges', - highlightProps: ['refs'], + highlightProps: ['ranges'], + beforeDemo: { view: 'md', source: [ + 'The `ranges` option (formerly `refs`) allows highlighting specific parts of the source text or turning them into links. The `ranges` value must be an array of objects with the following fields:', + '\n', + '- `range` (required) - an array of two numbers representing the start and end offsets of the span', + '- `className` - class name(s) for the wrapping element. This can be a string (a space-separated list of class names) or an array of strings. Predefined class names include `def`, `ref`, `global-ref`, and `error`, each specifying a particular style for the span.', + '- `href` - when specified, the range becomes a link (``), otherwise, the range is a regular span (``)', + '- `tooltip` - config for a [tooltip](#views-showcase&!anchor=tooltips), similar to tooltips in other views', + '- `marker` - a value added to the wrapping element as a `data-marker` attribute' + ] }, demo: { view: 'source', source: 'let span = "def + ref + global-ref + error";\nlet link = "def + ref + global-ref + error";\n\n// span with tooltip', syntax: 'js', - refs: [ + ranges: [ { range: [4, 8] }, { range: [12, 15], className: 'def' }, { range: [18, 21], className: 'ref' }, { range: [24, 34], className: 'global-ref' }, { range: [37, 42], className: 'error' }, - { range: [49, 53], type: 'link', href: '#' }, - { range: [57, 60], type: 'link', href: '#', className: 'def' }, - { range: [63, 66], type: 'link', href: '#', className: 'ref' }, - { range: [69, 79], type: 'link', href: '#', className: 'global-ref' }, - { range: [82, 87], type: 'link', href: '#', className: 'error' }, - { range: [104, 111], type: 'link', href: '#example', tooltip: { + { range: [49, 53], href: '#' }, + { range: [57, 60], href: '#', className: 'def' }, + { range: [63, 66], href: '#', className: 'ref' }, + { range: [69, 79], href: '#', className: 'global-ref' }, + { range: [82, 87], href: '#', className: 'error' }, + { range: [104, 111], href: '#example', tooltip: { position: 'trigger', - content: ['text:"Link to "', 'text:href'] + content: 'text:`Link to ${href}`' } } ] } }, { - title: 'Max content size for syntax highlight', - highlightProps: ['maxSourceSizeToHighlight'], - beforeDemo: ['md:"By default a syntax highlighing is not appling to a source longer than 250Kb. Option `maxSourceSizeToHighlight` is using to change max size of source to be syntax highlighted."'], + title: 'Marks', + highlightProps: ['marks'], + beforeDemo: { view: 'md', source: [ + 'The `marks` option allows injecting visual or text marks at specific points in the source text. The `marks` value must be an array of objects with the following fields:', + '\n', + '- `offset` (required) - the offset in the source where the mark is injected.', + '- `kind` - the type of mark, which can be one of the following: `span` (default), `dot`, `self` (self value), `nested` (nested value), `total` (total value), or `none`', + '- `className` - class name(s) for the wrapping element. This can be a string (a space-separated list of class names) or an array of strings. Predefined class names include `def`, `ref`, `global-ref`, `error`, and `inactive`, each specifying a particular style for the mark.', + '- `href` - when specified, the mark becomes a link (``), otherwise, it is a regular span (``)', + '- `content` - view config for content (e.g., `\'text:"hello"\'` or `{ view: \'name\', ... }`). If not specified, `kind` is ignored and defaults to `dot` (since content is optional for marks of type `dot`)', + '- `prefix` (ignored when `kind` is `dot`) - text to display before the content of mark, styled with a dimmed color ', + '- `postfix` (ignored when `kind` is `dot`) - text to display after the content of mark, styled with a dimmed color', + '- `tooltip` - config for a [tooltip](#views-showcase&!anchor=tooltips), similar to tooltips in other views', + '- `marker` - a value added to the wrapping element as a `data-marker` attribute' + ] }, demo: { view: 'source', - source: codeExample, + source: 'let span = "def + ref + global-ref + error";\nlet link = "def + ref + global-ref + error";\n\n// kinds: self nested total none\n// kind="dot": default def ref global-ref error inactive\n// kind="dot" with tooltip: \n// kind="dot" with href: \n// mark with tooltip', syntax: 'js', - maxSourceSizeToHighlight: 4 + marks: [ + { offset: 4 }, + { offset: 4, kind: 'dot', content: 'text:"dot with text"' }, + { offset: 4, content: 'text:"mark"' }, + { offset: 12, className: 'def', content: 'text:"def"' }, + { offset: 18, className: 'ref', content: 'text:"ref"' }, + { offset: 24, className: 'global-ref', content: 'text:"global-ref"' }, + { offset: 37, className: 'error', content: 'text:"error"' }, + + { offset: 49, href: '#' }, + { offset: 49, href: '#', kind: 'dot', content: 'text:"dot with text"' }, + { offset: 49, href: '#', content: 'text:"link"' }, + { offset: 57, href: '#', className: 'def', content: 'text:"def"' }, + { offset: 63, href: '#', className: 'ref', content: 'text:"ref"' }, + { offset: 69, href: '#', className: 'global-ref', content: 'text:"global-def"' }, + { offset: 82, href: '#', className: 'error', content: 'text:"error"' }, + + { offset: 101, kind: 'self', content: 'text:"123"', href: '#', postfix: 'ms' }, + { offset: 106, kind: 'nested', content: 'text:"123"', postfix: 'ms' }, + { offset: 113, kind: 'total', content: 'text:"123"', postfix: 'ms' }, + { offset: 119, kind: 'none', content: 'text:"none"' }, + { offset: 123, kind: 'none', content: 'text:"⚠️"' }, + { offset: 123, kind: 'none', content: 'text:"none"', prefix: 'prefix', postfix: ' postfix', href: '#test' }, + + { offset: 139, kind: 'dot' }, + { offset: 147, kind: 'dot', className: 'def' }, + { offset: 151, kind: 'dot', className: 'ref' }, + { offset: 155, kind: 'dot', className: 'global-ref' }, + { offset: 166, kind: 'dot', className: 'error' }, + { offset: 172, kind: 'dot', className: 'inactive' }, + + ...['', 'def', 'ref', 'global-ref', 'error', 'inactive'].map(className => ({ + offset: 209, + kind: 'dot', + className, + tooltip: 'text:' + JSON.stringify(className || 'default') + })), + + ...['', 'def', 'ref', 'global-ref', 'error', 'inactive'].map(className => ({ + offset: 235, + kind: 'dot', + className, + href: '#' + (className || 'default') + })), + + { offset: 256, href: '#example', content: 'text:"show tooltip"', tooltip: { + position: 'trigger', + content: 'text:`Link to ${href}`' + } } + ] } } ]