Ist es möglich, Zeilennummern im Codeblock anzuzeigen?

Ich habe auf dem Docker-Forum mit Zeilennummern sowohl für den Umbruch als auch für das Scrollen experimentiert. Dies ist noch nicht live geschaltet, aber ich würde gerne wissen, ob jemand Kommentare hat. Zuerst also einige Screenshots:

Umgebrochener Inhalt

Oben helfen Zeilennummern dabei, zu erkennen, wann eine Zeile aufhört und eine andere beginnt. Natürlich gibt es andere Möglichkeiten, dies darzustellen, aber dieses Thema dreht sich um Zeilennummern. :nerd_face:

Gescrollter Inhalt

Mit begrenztem Padding an der (den) Seite(n), wo Inhalt verborgen ist, um anzudeuten, dass durch Scrollen mehr angezeigt wird:

Oder mit Schatten, um anzuzeigen, wo der Inhalt horizontal verborgen ist:

Einige Rückmeldungen zeigten, dass die kleinen Schatten mit Scrollbalken verwechselt werden könnten, daher wäre ein anderes Styling vielleicht besser.

Hinzufügen der Zeilennummern auf Client-Seite

Bei der Verwendung von horizontalem Scrollen verwendet jede Zeile des Inhalts eine Zeile auf dem Bildschirm. Theoretisch könnte man also JavaScript verwenden, um ein vertikales <div> mit einer Reihe von Zeilennummern einzufügen und dieses neben den gescrollten Inhalt zu positionieren. Beim Umbruch kann sich jedoch eine einzelne Zeilennummer auf mehrere Zeilen des Inhalts beziehen, wofür die Verwendung von CSS span::before zusammen mit content: counter(...) einfacher erscheint. Das wird hier verwendet.

Um ::before zu verwenden, benötigt jede Zeile im vorformatierten Code-Block zunächst ein übergeordnetes Element, wie z. B. das Umhüllen in einem <span> mit api.decorateCookedElement. Dies erfordert, dass Letzteres vor dem Ausführen von highlight.js läuft, da highlight.js manchmal Tags nicht in derselben Zeile schließt, in der sie geöffnet wurden. Zum Beispiel, wenn es annimmt, dass etwas Mustache/Handlebars ist:

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

Das oben genannte Ergebnis ist das schließende </span> in der nächsten Zeile:

<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>

Natürlich könnte dies ein vorübergehender Fehler sein, den ich wahrscheinlich melden sollte. Aber egal, besser auf Nummer sicher gehen. Und die gute Nachricht: Es scheint, dass api.decorateCookedElement(...) heute tatsächlich vor highlight.js ausgeführt wird.

Das nächste JavaScript umhüllt jede Zeile in einem <span> und fügt die CSS-Klasse lines-scroll oder lines-wrap hinzu, um anzugeben, welcher Stil gewünscht ist (man könnte dem Benutzer sogar erlauben, dies umzuschalten). Es fügt auch lines-shadow hinzu, um die Scroll-Schatten zu aktivieren. Außerdem werden einige CSS-Klassen an das übergeordnete <pre> und <code> hinzugefügt, um das Styling zu unterstützen und sicherzustellen, dass Dinge nur einmal dekoriert werden.

Theme JavaScript Decorator

Das Folgende aktiviert auch Zeilennummern und das andere Styling in der Editor-Vorschau. Ändern Sie die letzte Zeile, um onlyStream: true einzuschließen, um dies zu deaktivieren:

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

Fügen Sie alles Folgende zum „Common, Head“ Ihrer Komponente in /admin/customize/themes/ hinzu:

<script type="text/discourse-plugin" version="0.8">
const decorateLines = (post) => {
  try {
    // Kombinationen aus 'number', 'wrap', 'scroll' und/oder '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 verwendet `<aside>` für Zitate anderer Forumsposts
      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(' ');

      // Nehmen wir an, es gibt keine Event-Listener auf der Zeile,
      // und trimmen, um abschließende leere Zeilen zu verstecken
      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

Das Folgende unterstützt Umbruch mit und ohne Zeilennummerierung sowie Scrollen mit oder ohne Zeilennummerierung und/oder Schatten. Es unterdrückt auch die Zeilennummerierung, wenn es nur eine einzelne Zeile gibt.

Dies fügt Zitate anderer Beiträge keine Zeilennummern hinzu, da ein solches Zitat, wenn es nur ein Teilzitat ist, immer noch mit 1 beginnt. Um die Nummerierung für Zitate hinzuzufügen, entfernen Sie :not(.lines-in-quote) von der Zeile unten (die mehrfach im SCSS vorkommt):

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

Das oben Genannte ist auch der Ort, an dem die Nummerierung für einzeilige Code-Blöcke unterdrückt wird.

Theme Sass Styling

Fügen Sie dies zum „Common, CSS“ Ihrer Komponente in /admin/customize/themes/ hinzu:

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);
  // Kein Padding für das äußere PRE:
  // - Scrollbalken bedecken CODE nicht, sondern liegen nahe an den äußersten Rändern
  // - Text nahe an den Rändern, wenn verborgener Inhalt vorhanden ist
  // - ebenso für die Schatten
  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 {
    // Mindesthöhe, um Rahmen und Hintergrund für leere Zeilen zu erhalten (Firefox
    // benötigt dies nur für das ::before, aber Chrome auch für das span)
    min-height: 1.5em;

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

  &.lines-shadow {
    background-image:
      // Die ersten beiden Überdeckungen, die mit dem Inhalt scrollen und daher
      // vor den Schatten erscheinen, wenn kein Scrollen möglich ist. Diese
      // entsprechen der Hintergrundfarbe für die ersten 25 %, gefolgt von einem Verlauf
      // bis zur vollen Transparenz (unter der Annahme, dass die Überdeckungen viermal so breit
      // sind wie die Schatten).
      linear-gradient(to right, var(--lines-bgcolor) 25%, transparent),
      linear-gradient(to left, var(--lines-bgcolor) 25%, transparent),
      // Und dahinter zwei Schatten an festen Positionen, um scrollbaren,
      // verborgenen Inhalt anzuzeigen, falls nicht überdeckt.
      linear-gradient(to right, var(--lines-shadow-color), transparent),
      linear-gradient(to left, var(--lines-shadow-color), transparent);
      // Oder:
      // 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:
      // Setzen Sie die Überdeckung auf das Vierfache der Breite des tatsächlichen Schattens, um mit
      // dem 25 %-Stop im Verlauf der Überdeckung übereinzustimmen
      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) {
      // Wenn Zeilennummern sichtbar sind, müssen wir den Schatten verschieben
      --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);
    }
  }
}

Entwicklung

Für die Entwicklung habe ich den folgenden Code verwendet, der es ermöglicht, URL-Abfrageparameter wie ?avbPreview festzulegen, um den JavaScript-Decorator-Hook zu aktivieren, und Dinge wie ?avbPreview&avbClasses=number,scroll, um Zeilennummern mit Scrollen aber ohne Schatten zu testen. Da alle CSS-Bereiche abgegrenzt sind, sollte dies andere Benutzer während des Tests nicht beeinflussen.

Entwicklungs-/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 verwendet `<aside>` für Zitate anderer Forumsposts
      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('===== Umschreiben; Quelle\n' + elem.innerHTML.trim());
        console.log('===== Umschreiben; Ergebnis\n' + elem.innerHTML.trim().replace(split, `<span class="${lineClass}">$1</span>`));
      }

      // Nehmen wir an, es gibt keine Event-Listener auf der Zeile,
      // und trimmen, um abschließende leere Zeilen zu verstecken
      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>