Is it possible to show line numbers in code block?

I’ve been experimenting with line numbers for both wrapping and scrolling on the Docker forum. This is not live yet, but I’d like to know if someone has any comments. So, some screenshots first:

Wrapped content

Above, line numbers help identifying when one line stops and another starts. Of course, there are other ways to render that, but this topic is about line numbers. :nerd_face:

Scrolled content

With limited padding on the side(s) where content is hidden, to indicate that scrolling will reveal more:

Or with shadows to indicate where content is hidden horizontally:

Some feedback I got showed that the small shadows may be confused with scrollbars, so different styling may be great.

Adding the line numbers client-side

When using horizontal scrolling, each line of content will use one line on the screen. So, in theory one could use JavaScript to inject a vertical <div> with a range of line numbers, and position that next to the scrolling content. But when wrapping, a single line number may apply to multiple lines of content, for which using CSS span::before along with content: counter(...) seems easier. That is what is used here.

To use ::before, each line in the preformatted code block first needs some parent element, like being wrapped in a <span> using api.decorateCookedElement. This needs the latter to run before highlight.js runs, as sometimes highlight.js does not close tags on the same line it opened them. For example, when it thinks something is Mustache/Handlebars:

docker image ls --format '{{slice (printf "%s:%s" .Repository .Tag) 0 11}}'

The above yields the closing </span> on the next line:

<code class="lang-handlebars hljs"><span class="xml">docker image ls --format '</span><span class="hljs-template-variable">{{<span class="hljs-name">slice</span> (<span class="hljs-name">printf</span> <span class="hljs-string">"%s:%s"</span> .Repository .Tag) <span class="hljs-number">0</span> <span class="hljs-number">11</span>}}</span><span class="xml">'
</span></code>

Of course, this may be a temporary bug which I probably should report. But regardless, better be safe. And the good news: it seems, today, api.decorateCookedElement(...) indeed runs prior to highlight.js.

The next JavaScript wraps each line in a <span> and adds CSS class lines-scroll or lines-wrap to indicate what style is wanted (one could even let the user toggle that). It also adds lines-shadow to enable the scroll shadows. And it adds some CSS classes to the parent <pre> and <code> to support styling, and to ensure things are only ever decorated once.

Theme JavaScript decorator

The following also enables line numbers and the other styling in the editor preview. Change the last line to include onlyStream: true to disable that:

api.decorateCookedElement(decorateLines, {id: 'decorate-pre-code-lines', onlyStream: true});

Add all of the following to your component’s “Common, Head” in /admin/customize/themes/:

<script type="text/discourse-plugin" version="0.8">
const decorateLines = (post) => {
  try {
    // Combinations of 'number', 'wrap', 'scroll' and/or 'shadow'
    const classes = ['decorator', 'number', 'scroll', 'shadow'].map(c => `lines-${c}`);

    const split = /^(.*)$/mg;
    const elems = post.querySelectorAll('pre code:not(.lines-decorator)');
    elems.forEach(elem => {
      const count = elem.innerHTML.trim().match(split).length;

      // Discourse uses `<aside>` for quotes of other forum posts
      const quote = elem.closest('aside') ? ['lines-in-quote'] : []

      elem.parentElement.classList.add(`lines-count-${count}`, ...classes, ...quote);
      elem.classList.add(`lines-count-${count}`, ...classes, ...quote);
      const lineClass = ['lines-line', ...quote].join(' ');

      // Assume we don't have any event listeners on the line,
      // and trim to hide trailing blank lines
      elem.innerHTML = elem.innerHTML.trim().replace(/^(.*)$/mg, `<span class="${lineClass}">$1</span>`);
    });
  } catch (e) {
    console.error(e);
  }
}

api.decorateCookedElement(decorateLines, {id: 'decorate-pre-code-lines'});
</script>

Sass SCSS styling

The following supports wrapping with and without line numbering, and scrolling with or without line numbering and/or shadows. It also suppresses line numbering when there is just a single line.

This does not add line numbers to quotes of other posts, as if such quote is just a partial quote, it will still start numbering with 1. To add numbering to quotes, remove the :not(.lines-in-quote) from the line below (which occurs multiple times in the SCSS):

&.lines-number:not(.lines-count-1):not(.lines-in-quote) {

The above is also where the numbering for single-line code blocks is being suppressed.

Theme Sass styling

Add this to your component’s “Common, CSS” in /admin/customize/themes/:

pre.lines-decorator code.lines-decorator span.lines-line {
  &::before {
    box-sizing: content-box;
  }
}

pre.lines-decorator {
  --lines-bgcolor: var(--hljs-bg);
  --lines-number-width: 2em;
  --lines-number-padding-to-right-border: .5em;
  --lines-shadow-width: .8em;
  --lines-shadow-color: rgba(0, 0, 0, .15);
  // No padding for outer PRE:
  // - scrollbars not covering CODE but close to outermost edges
  // - text close to the edges when there is hidden content
  // - likewise for the shadows
  padding: 0;
  // #f5f5f5 in `code.less`
  background-color: var(--lines-bgcolor);
}

code.lines-decorator {
  padding: 1.5em;

  &.lines-wrap {
    white-space: pre-wrap;

    &:not(.lines-count-1) {
      span.lines-line {
        display: inline-block;
        position: relative;
        padding-left: 3em;
      }
    }
  }

  &.lines-scroll {
    white-space: pre;

    span.lines-line {
      padding-right: 1.5em;

      &::before {
        position: sticky;
        left: 0;
      }
    }
  }

  &.lines-wrap span.lines-line {
    // Minimum height to get border and background for empty lines (Firefox
    // only needs this for the ::before, but Chrome also for the span)
    min-height: 1.5em;

    &::before {
      position: absolute;
      left: 0;
    }
  }

  &.lines-shadow {
    background-image:
      // First two covers that scroll with the content, hence appearing
      // in front of the shadows when no scrolling is possible. These
      // match the background color for the first 25%, and next a gradient
      // to full transparency (hence assuming the covers are 4 times the
      // width of the shadows).
      linear-gradient(to right, var(--lines-bgcolor) 25%, transparent),
      linear-gradient(to left, var(--lines-bgcolor) 25%, transparent),
      // And behind that two shadows at fixed positions, to indicate
      // scrollable, hidden content if not covered.
      linear-gradient(to right, var(--lines-shadow-color), transparent),
      linear-gradient(to left, var(--lines-shadow-color), transparent);
      // Or:
      // radial-gradient(farthest-side at 0px 50%, var(--lines-shadow-color), transparent),
      // radial-gradient(farthest-side at 100% 50%, var(--lines-shadow-color), transparent);

    background-size:
      // Set the cover to 4 times the width of the actual shadow, to match
      // the 25% stop in the gradient of the cover
      calc(4 * var(--lines-shadow-width)) 100%,
      calc(4 * var(--lines-shadow-width)) 100%,
      var(--lines-shadow-width) 100%,
      var(--lines-shadow-width) 100%;

    --shadow-left: 0;

    &.lines-number:not(.lines-count-1):not(.lines-in-quote) {
      // If line numbers are visible, we need to move the shadow
      --shadow-left: calc(var(--lines-number-width) + var(--lines-number-padding-to-right-border));
    }

    background-position: var(--shadow-left) center, right center, var(--shadow-left) center, right center;

    background-attachment: local, local, scroll, scroll;
    background-repeat: no-repeat;
  }

  &.lines-number:not(.lines-count-1):not(.lines-in-quote) {
    padding-left: 0;
    counter-reset: line-numbering;

    span.lines-line::before {
      display: inline-block;
      content: counter(line-numbering);
      counter-increment: line-numbering;
      border-right: 1px solid #ccc;
      width: var(--lines-number-width);
      padding-right: var(--lines-number-padding-to-right-border);
      margin-right: .5em;
      height: 100%;
      text-align: right;
      font-family: var(--font-family);
      color: var(--primary-medium);
      background-color: var(--lines-bgcolor);
    }
  }
}

Development

For development I used the next code, allowing to set URL query parameters such as ?avbPreview to enable the JavaScript decorator hook, and things like ?avbPreview&avbClasses=number,scroll to test line numbers with scrolling but no shadows. As all CSS is scoped, this should not affect other users during testing.

Development/debug version
<script type="text/discourse-plugin" version="0.8">
const decorateLines = (post) => {
  try {
    const params = new URL(document.location.href).searchParams;
    const preview = params.has('avbPreview');
    if (!preview) {
      return;
    }
    const classes = ['decorator'].concat(
      (params.get('avbClasses') || 'number,scroll,shadow').split(',')).map(c => `lines-${c}`);
    const debug = params.has('avbDebug');
    const split = /^(.*)$/mg;
    const elems = post.querySelectorAll('pre code:not(.lines-decorator)');
    elems.forEach(elem => {
      const count = elem.innerHTML.trim().match(split).length;
      // Discourse uses `<aside>` for quotes of other forum posts
      const quote = elem.closest('aside') ? ['lines-in-quote'] : []
      elem.parentElement.classList.add(`lines-count-${count}`, ...classes, ...quote);
      elem.classList.add(`lines-count-${count}`, ...classes, ...quote);
      const lineClass = ['lines-line', ...quote].join(' ');

      if (debug) {
        console.log('===== Rewriting; source\n' + elem.innerHTML.trim());
        console.log('===== Rewriting; result\n' + elem.innerHTML.trim().replace(split, `<span class="${lineClass}">$1</span>`));
      }

      // Assume we don't have any event listeners on the line,
      // and trim to hide trailing blank lines
      elem.innerHTML = elem.innerHTML.trim().replace(/^(.*)$/mg, `<span class="${lineClass}">$1</span>`);
    });
  } catch (e) {
    console.error(e);
  }
}

api.decorateCookedElement(decorateLines, {id: 'decorate-pre-code-lines'});
</script>
7 Likes