web-dev-qa-db-fra.com

Fenêtre de journal élégante dans WinForms C #

Je cherche des idées sur un moyen efficace d'implémenter une fenêtre de journal pour une application Windows Forms. Dans le passé, j'en ai implémenté plusieurs en utilisant TextBox et RichTextBox mais je ne suis toujours pas totalement satisfait de la fonctionnalité.

Ce journal est destiné à fournir à l'utilisateur un historique récent de divers événements, principalement utilisé dans les applications de collecte de données où l'on peut être curieux de savoir comment une transaction particulière s'est terminée. Dans ce cas, le journal n'a pas besoin d'être permanent ni enregistré dans un fichier.

Tout d'abord, certaines exigences proposées:

  • Efficace et rapide; si des centaines de lignes sont écrites dans le journal en succession rapide, il doit consommer un minimum de ressources et de temps.
  • Être en mesure d'offrir un défilement variable jusqu'à 2 000 lignes environ. Rien de plus n'est inutile.
  • La mise en évidence et la couleur sont préférées. Effets de police non requis.
  • Découpez automatiquement les lignes lorsque la limite de défilement est atteinte.
  • Faites défiler automatiquement lorsque de nouvelles données sont ajoutées.
  • Bonus mais non requis: suspendre le défilement automatique pendant l'interaction manuelle, par exemple si l'utilisateur parcourt l'historique.

Ce que j'ai utilisé jusqu'à présent pour écrire et rogner le journal:

J'utilise le code suivant (que j'appelle à partir d'autres threads):

// rtbLog is a RichTextBox
// _MaxLines is an int
public void AppendLog(string s, Color c, bool bNewLine)
{
    if (rtbLog.InvokeRequired)
    {
        object[] args = { s, c, bNewLine };
        rtbLog.Invoke(new AppendLogDel(AppendLog), args);
        return;
    }
    try
    {
        rtbLog.SelectionColor = c;
        rtbLog.AppendText(s);
        if (bNewLine) rtbLog.AppendText(Environment.NewLine);
        TrimLog();
        rtbLog.SelectionStart = rtbLog.TextLength;
        rtbLog.ScrollToCaret();
        rtbLog.Update();
    }
    catch (Exception exc)
    {
        // exception handling
    }
}

private void TrimLog()
{
    try
    {
        // Extra lines as buffer to save time
        if (rtbLog.Lines.Length < _MaxLines + 10)
        {
            return;
        }
        else
        {
            string[] sTemp = rtxtLog.Lines;
            string[] sNew= new string[_MaxLines];
            int iLineOffset = sTemp.Length - _MaxLines;
            for (int n = 0; n < _MaxLines; n++)
            {
                sNew[n] = sTemp[iLineOffset];
                iLineOffset++;
            }
            rtbLog.Lines = sNew;
        }
    }
    catch (Exception exc)
    {
        // exception handling
    }
}

Le problème avec cette approche est que chaque fois que TrimLog est appelé, je perds le formatage des couleurs. Avec une TextBox régulière, cela fonctionne très bien (avec un peu de modification bien sûr).

La recherche d'une solution n'a jamais été vraiment satisfaisante. Certains suggèrent de réduire l'excédent par le nombre de caractères au lieu du nombre de lignes dans une RichTextBox. J'ai également vu des ListBox utilisées, mais je n'ai pas réussi à l'essayer.

54
JYelton

Je vous recommande de ne pas utiliser du tout de contrôle comme journal. Au lieu de cela, écrivez une classe log collection qui a les propriétés que vous désirez (sans inclure les propriétés d'affichage).

Ensuite, écrivez le peu de code nécessaire pour vider cette collection dans une variété d'éléments d'interface utilisateur. Personnellement, je mettrais les méthodes SendToEditControl et SendToListBox dans mon objet de journalisation. J'ajouterais probablement des capacités de filtrage à ces méthodes.

Vous pouvez mettre à jour le journal de l'interface utilisateur uniquement aussi souvent que cela est logique, ce qui vous donne les meilleures performances possibles et, plus important encore, vous permet de réduire la surcharge de l'interface utilisateur lorsque le journal change rapidement.

L'important n'est pas de lier votre journalisation à un morceau d'interface utilisateur, c'est une erreur. Un jour, vous voudrez peut-être courir sans tête.

À long terme, une bonne interface utilisateur pour un enregistreur est probablement un contrôle personnalisé. Mais à court terme, vous voulez simplement déconnecter votre journalisation de tout spécifique morceau d'interface utilisateur.

25
John Knoeller

Voici quelque chose que j'ai créé sur la base d'un enregistreur beaucoup plus sophistiqué que j'ai écrit il y a un moment.

Cela prendra en charge la couleur dans la zone de liste en fonction du niveau de journal, prend en charge Ctrl + V et clic droit pour la copie au format RTF et gère la journalisation dans la ListBox à partir d'autres threads.

Vous pouvez remplacer le nombre de lignes conservées dans la ListBox (2000 par défaut) ainsi que le format de message en utilisant l'une des surcharges du constructeur.

using System;
using System.Drawing;
using System.Windows.Forms;
using System.Threading;
using System.Text;

namespace StackOverflow
{
    public partial class Main : Form
    {
        public static ListBoxLog listBoxLog;
        public Main()
        {
            InitializeComponent();

            listBoxLog = new ListBoxLog(listBox1);

            Thread thread = new Thread(LogStuffThread);
            thread.IsBackground = true;
            thread.Start();
        }

        private void LogStuffThread()
        {
            int number = 0;
            while (true)
            {
                listBoxLog.Log(Level.Info, "A info level message from thread # {0,0000}", number++);
                Thread.Sleep(2000);
            }
        }

        private void button1_Click(object sender, EventArgs e)
        {
            listBoxLog.Log(Level.Debug, "A debug level message");
        }
        private void button2_Click(object sender, EventArgs e)
        {
            listBoxLog.Log(Level.Verbose, "A verbose level message");
        }
        private void button3_Click(object sender, EventArgs e)
        {
            listBoxLog.Log(Level.Info, "A info level message");
        }
        private void button4_Click(object sender, EventArgs e)
        {
            listBoxLog.Log(Level.Warning, "A warning level message");
        }
        private void button5_Click(object sender, EventArgs e)
        {
            listBoxLog.Log(Level.Error, "A error level message");
        }
        private void button6_Click(object sender, EventArgs e)
        {
            listBoxLog.Log(Level.Critical, "A critical level message");
        }
        private void button7_Click(object sender, EventArgs e)
        {
            listBoxLog.Paused = !listBoxLog.Paused;
        }
    }

    public enum Level : int
    {
        Critical = 0,
        Error = 1,
        Warning = 2,
        Info = 3,
        Verbose = 4,
        Debug = 5
    };
    public sealed class ListBoxLog : IDisposable
    {
        private const string DEFAULT_MESSAGE_FORMAT = "{0} [{5}] : {8}";
        private const int DEFAULT_MAX_LINES_IN_LISTBOX = 2000;

        private bool _disposed;
        private ListBox _listBox;
        private string _messageFormat;
        private int _maxEntriesInListBox;
        private bool _canAdd;
        private bool _paused;

        private void OnHandleCreated(object sender, EventArgs e)
        {
            _canAdd = true;
        }
        private void OnHandleDestroyed(object sender, EventArgs e)
        {
            _canAdd = false;
        }
        private void DrawItemHandler(object sender, DrawItemEventArgs e)
        {
            if (e.Index >= 0)
            {
                e.DrawBackground();
                e.DrawFocusRectangle();

                LogEvent logEvent = ((ListBox)sender).Items[e.Index] as LogEvent;

                // SafeGuard against wrong configuration of list box
                if (logEvent == null)
                {
                    logEvent = new LogEvent(Level.Critical, ((ListBox)sender).Items[e.Index].ToString());
                }

                Color color;
                switch (logEvent.Level)
                {
                    case Level.Critical:
                        color = Color.White;
                        break;
                    case Level.Error:
                        color = Color.Red;
                        break;
                    case Level.Warning:
                        color = Color.Goldenrod;
                        break;
                    case Level.Info:
                        color = Color.Green;
                        break;
                    case Level.Verbose:
                        color = Color.Blue;
                        break;
                    default:
                        color = Color.Black;
                        break;
                }

                if (logEvent.Level == Level.Critical)
                {
                    e.Graphics.FillRectangle(new SolidBrush(Color.Red), e.Bounds);
                }
                e.Graphics.DrawString(FormatALogEventMessage(logEvent, _messageFormat), new Font("Lucida Console", 8.25f, FontStyle.Regular), new SolidBrush(color), e.Bounds);
            }
        }
        private void KeyDownHandler(object sender, KeyEventArgs e)
        {
            if ((e.Modifiers == Keys.Control) && (e.KeyCode == Keys.C))
            {
                CopyToClipboard();
            }
        }
        private void CopyMenuOnClickHandler(object sender, EventArgs e)
        {
            CopyToClipboard();
        }
        private void CopyMenuPopupHandler(object sender, EventArgs e)
        {
            ContextMenu menu = sender as ContextMenu;
            if (menu != null)
            {
                menu.MenuItems[0].Enabled = (_listBox.SelectedItems.Count > 0);
            }
        }

        private class LogEvent
        {
            public LogEvent(Level level, string message)
            {
                EventTime = DateTime.Now;
                Level = level;
                Message = message;
            }

            public readonly DateTime EventTime;

            public readonly Level Level;
            public readonly string Message;
        }
        private void WriteEvent(LogEvent logEvent)
        {
            if ((logEvent != null) && (_canAdd))
            {
                _listBox.BeginInvoke(new AddALogEntryDelegate(AddALogEntry), logEvent);
            }
        }
        private delegate void AddALogEntryDelegate(object item);
        private void AddALogEntry(object item)
        {
            _listBox.Items.Add(item);

            if (_listBox.Items.Count > _maxEntriesInListBox)
            {
                _listBox.Items.RemoveAt(0);
            }

            if (!_paused) _listBox.TopIndex = _listBox.Items.Count - 1;
        }
        private string LevelName(Level level)
        {
            switch (level)
            {
                case Level.Critical: return "Critical";
                case Level.Error: return "Error";
                case Level.Warning: return "Warning";
                case Level.Info: return "Info";
                case Level.Verbose: return "Verbose";
                case Level.Debug: return "Debug";
                default: return string.Format("<value={0}>", (int)level);
            }
        }
        private string FormatALogEventMessage(LogEvent logEvent, string messageFormat)
        {
            string message = logEvent.Message;
            if (message == null) { message = "<NULL>"; }
            return string.Format(messageFormat,
                /* {0} */ logEvent.EventTime.ToString("yyyy-MM-dd HH:mm:ss.fff"),
                /* {1} */ logEvent.EventTime.ToString("yyyy-MM-dd HH:mm:ss"),
                /* {2} */ logEvent.EventTime.ToString("yyyy-MM-dd"),
                /* {3} */ logEvent.EventTime.ToString("HH:mm:ss.fff"),
                /* {4} */ logEvent.EventTime.ToString("HH:mm:ss"),

                /* {5} */ LevelName(logEvent.Level)[0],
                /* {6} */ LevelName(logEvent.Level),
                /* {7} */ (int)logEvent.Level,

                /* {8} */ message);
        }
        private void CopyToClipboard()
        {
            if (_listBox.SelectedItems.Count > 0)
            {
                StringBuilder selectedItemsAsRTFText = new StringBuilder();
                selectedItemsAsRTFText.AppendLine(@"{\rtf1\ansi\deff0{\fonttbl{\f0\fcharset0 Courier;}}");
                selectedItemsAsRTFText.AppendLine(@"{\colortbl;\red255\green255\blue255;\red255\green0\blue0;\red218\green165\blue32;\red0\green128\blue0;\red0\green0\blue255;\red0\green0\blue0}");
                foreach (LogEvent logEvent in _listBox.SelectedItems)
                {
                    selectedItemsAsRTFText.AppendFormat(@"{{\f0\fs16\chshdng0\chcbpat{0}\cb{0}\cf{1} ", (logEvent.Level == Level.Critical) ? 2 : 1, (logEvent.Level == Level.Critical) ? 1 : ((int)logEvent.Level > 5) ? 6 : ((int)logEvent.Level) + 1);
                    selectedItemsAsRTFText.Append(FormatALogEventMessage(logEvent, _messageFormat));
                    selectedItemsAsRTFText.AppendLine(@"\par}");
                }
                selectedItemsAsRTFText.AppendLine(@"}");
                System.Diagnostics.Debug.WriteLine(selectedItemsAsRTFText.ToString());
                Clipboard.SetData(DataFormats.Rtf, selectedItemsAsRTFText.ToString());
            }

        }

        public ListBoxLog(ListBox listBox) : this(listBox, DEFAULT_MESSAGE_FORMAT, DEFAULT_MAX_LINES_IN_LISTBOX) { }
        public ListBoxLog(ListBox listBox, string messageFormat) : this(listBox, messageFormat, DEFAULT_MAX_LINES_IN_LISTBOX) { }
        public ListBoxLog(ListBox listBox, string messageFormat, int maxLinesInListbox)
        {
            _disposed = false;

            _listBox = listBox;
            _messageFormat = messageFormat;
            _maxEntriesInListBox = maxLinesInListbox;

            _paused = false;

            _canAdd = listBox.IsHandleCreated;

            _listBox.SelectionMode = SelectionMode.MultiExtended;

            _listBox.HandleCreated += OnHandleCreated;
            _listBox.HandleDestroyed += OnHandleDestroyed;
            _listBox.DrawItem += DrawItemHandler;
            _listBox.KeyDown += KeyDownHandler;

            MenuItem[] menuItems = new MenuItem[] { new MenuItem("Copy", new EventHandler(CopyMenuOnClickHandler)) };
            _listBox.ContextMenu = new ContextMenu(menuItems);
            _listBox.ContextMenu.Popup += new EventHandler(CopyMenuPopupHandler);

            _listBox.DrawMode = DrawMode.OwnerDrawFixed;
        }

        public void Log(string message) { Log(Level.Debug, message); }
        public void Log(string format, params object[] args) { Log(Level.Debug, (format == null) ? null : string.Format(format, args)); }
        public void Log(Level level, string format, params object[] args) { Log(level, (format == null) ? null : string.Format(format, args)); }
        public void Log(Level level, string message)
        {
            WriteEvent(new LogEvent(level, message));
        }

        public bool Paused
        {
            get { return _paused; }
            set { _paused = value; }
        }

        ~ListBoxLog()
        {
            if (!_disposed)
            {
                Dispose(false);
                _disposed = true;
            }
        }
        public void Dispose()
        {
            if (!_disposed)
            {
                Dispose(true);
                GC.SuppressFinalize(this);
                _disposed = true;
            }
        }
        private void Dispose(bool disposing)
        {
            if (_listBox != null)
            {
                _canAdd = false;

                _listBox.HandleCreated -= OnHandleCreated;
                _listBox.HandleCreated -= OnHandleDestroyed;
                _listBox.DrawItem -= DrawItemHandler;
                _listBox.KeyDown -= KeyDownHandler;

                _listBox.ContextMenu.MenuItems.Clear();
                _listBox.ContextMenu.Popup -= CopyMenuPopupHandler;
                _listBox.ContextMenu = null;

                _listBox.Items.Clear();
                _listBox.DrawMode = DrawMode.Normal;
                _listBox = null;
            }
        }
    }
}
25
Stefan

Je vais le stocker ici pour aider Future Me lorsque je souhaite utiliser une RichTextBox pour enregistrer à nouveau les lignes colorées. Le code suivant supprime la première ligne d'un RichTextBox:

if ( logTextBox.Lines.Length > MAX_LINES )
{
  logTextBox.Select(0, logTextBox.Text.IndexOf('\n')+1);
  logTextBox.SelectedRtf = "{\\rtf1\\ansi\\ansicpg1252\\deff0\\deflang1053\\uc1 }";
}

Il m'a fallu beaucoup trop de temps pour comprendre que le réglage de SelectedRtf sur "" ne fonctionnait pas, mais que le réglage sur "bon" RTF sans contenu textuel est correct.

13
m_eiman

J'ai récemment mis en œuvre quelque chose de similaire. Notre approche consistait à conserver un tampon en anneau des enregistrements de défilement et à peindre le texte du journal manuellement (avec Graphics.DrawString). Ensuite, si l'utilisateur veut revenir en arrière, copier du texte, etc., nous avons un bouton "Pause" qui retourne à un contrôle TextBox normal.

5
Daniel Pryden

Je dirais que ListView est parfait pour cela (en mode d'affichage détaillé), et c'est exactement ce pour quoi je l'utilise dans quelques applications internes.

Conseil utile: utilisez BeginUpdate () et EndUpdate () si vous savez que vous ajouterez/supprimerez de nombreux éléments à la fois.

4
Neil N

Si vous souhaitez mettre en évidence et mettre en forme les couleurs, je vous suggère une RichTextBox.

Si vous souhaitez le défilement automatique, utilisez la ListBox.

Dans les deux cas, liez-le à un tampon circulaire de lignes.

2
Cheeso

Ma solution pour créer une fenêtre de journal de base était exactement comme John Knoeller suggérée dans sa réponse. Évitez de stocker les informations de journal directement dans un contrôle TextBox ou RichTextBox, mais créez plutôt une classe de journalisation qui peut être utilisée pour remplir un contrôle, ou écrire dans un fichier , etc.

Cet exemple de solution comporte quelques éléments:

  1. La classe de journalisation elle-même, Logger.
  2. Modification du contrôle RichTextBox pour ajouter une fonctionnalité de défilement vers le bas après une mise à jour; ScrollingRichTextBox.
  3. Le formulaire principal pour démontrer son utilisation, LoggerExample.

Tout d'abord, la classe de journalisation:

using System;
using System.Collections.Generic;
using System.Drawing;
using System.Linq;
using System.Text;

namespace Logger
{
    /// <summary>
    /// A circular buffer style logging class which stores N items for display in a Rich Text Box.
    /// </summary>
    public class Logger
    {
        private readonly Queue<LogEntry> _log;
        private uint _entryNumber;
        private readonly uint _maxEntries;
        private readonly object _logLock = new object();
        private readonly Color _defaultColor = Color.White;

        private class LogEntry
        {
            public uint EntryId;
            public DateTime EntryTimeStamp;
            public string EntryText;
            public Color EntryColor;
        }

        private struct ColorTableItem
        {
            public uint Index;
            public string RichColor;
        }

        /// <summary>
        /// Create an instance of the Logger class which stores <paramref name="maximumEntries"/> log entries.
        /// </summary>
        public Logger(uint maximumEntries)
        {
            _log = new Queue<LogEntry>();
            _maxEntries = maximumEntries;
        }

        /// <summary>
        /// Retrieve the contents of the log as rich text, suitable for populating a <see cref="System.Windows.Forms.RichTextBox.Rtf"/> property.
        /// </summary>
        /// <param name="includeEntryNumbers">Option to prepend line numbers to each entry.</param>
        public string GetLogAsRichText(bool includeEntryNumbers)
        {
            lock (_logLock)
            {
                var sb = new StringBuilder();

                var uniqueColors = BuildRichTextColorTable();
                sb.AppendLine($@"{{\rtf1{{\colortbl;{ string.Join("", uniqueColors.Select(d => d.Value.RichColor)) }}}");

                foreach (var entry in _log)
                {
                    if (includeEntryNumbers)
                        sb.Append($"\\cf1 { entry.EntryId }. ");

                    sb.Append($"\\cf1 { entry.EntryTimeStamp.ToShortDateString() } { entry.EntryTimeStamp.ToShortTimeString() }: ");

                    var richColor = $"\\cf{ uniqueColors[entry.EntryColor].Index + 1 }";
                    sb.Append($"{ richColor } { entry.EntryText }\\par").AppendLine();
                }
                return sb.ToString();
            }
        }

        /// <summary>
        /// Adds <paramref name="text"/> as a log entry.
        /// </summary>
        public void AddToLog(string text)
        {
            AddToLog(text, _defaultColor);
        }

        /// <summary>
        /// Adds <paramref name="text"/> as a log entry, and specifies a color to display it in.
        /// </summary>
        public void AddToLog(string text, Color entryColor)
        {
            lock (_log)
            {
                if (_entryNumber >= uint.MaxValue)
                    _entryNumber = 0;
                _entryNumber++;
                var logEntry = new LogEntry { EntryId = _entryNumber, EntryTimeStamp = DateTime.Now, EntryText = text, EntryColor = entryColor };
                _log.Enqueue(logEntry);

                while (_log.Count > _maxEntries)
                    _log.Dequeue();
            }
        }

        /// <summary>
        /// Clears the entire log.
        /// </summary>
        public void Clear()
        {
            lock (_logLock)
            {
                _log.Clear();
            }
        }

        private Dictionary<Color, ColorTableItem> BuildRichTextColorTable()
        {
            var uniqueColors = new Dictionary<Color, ColorTableItem>();
            var index = 0u;

            uniqueColors.Add(_defaultColor, new ColorTableItem() { Index = index++, RichColor = ColorToRichColorString(_defaultColor) });

            foreach (var c in _log.Select(l => l.EntryColor).Distinct().Where(c => c != _defaultColor))
                uniqueColors.Add(c, new ColorTableItem() { Index = index++, RichColor = ColorToRichColorString(c) });

            return uniqueColors;
        }

        private string ColorToRichColorString(Color c)
        {
            return $"\\red{c.R}\\green{c.G}\\blue{c.B};";
        }
    }
}

La classe Logger incorpore une autre classe LogEntry qui garde une trace du numéro de ligne, de l'horodatage et de la couleur souhaitée. Une structure est utilisée pour créer une table de couleurs RTF.

Ensuite, voici la RichTextBox modifiée:

using System;
using System.Runtime.InteropServices;

namespace Logger
{
    public class ScrollingRichTextBox : System.Windows.Forms.RichTextBox
    {
        [DllImport("user32.dll", CharSet = CharSet.Auto)]
        private static extern IntPtr SendMessage(
            IntPtr hWnd,
            uint Msg,
            IntPtr wParam,
            IntPtr LParam);

        private const int _WM_VSCROLL = 277;
        private const int _SB_BOTTOM = 7;

        /// <summary>
        /// Scrolls to the bottom of the RichTextBox.
        /// </summary>
        public void ScrollToBottom()
        {
            SendMessage(Handle, _WM_VSCROLL, new IntPtr(_SB_BOTTOM), new IntPtr(0));
        }
    }
}

Tout ce que je fais ici est d'hériter d'une RichTextBox et d'ajouter une méthode de "défilement vers le bas". Il existe diverses autres questions sur la façon de procéder sur StackOverflow, dont j'ai dérivé cette approche.

Enfin, un exemple d'utilisation de cette classe à partir d'un formulaire:

using System;
using System.Collections.Generic;
using System.Drawing;
using System.Windows.Forms;

namespace Logger
{
    public partial class LoggerExample : Form
    {
        private Logger _log = new Logger(100u);
        private List<Color> _randomColors = new List<Color> { Color.Red, Color.SkyBlue, Color.Green };
        private Random _r = new Random((int)DateTime.Now.Ticks);

        public LoggerExample()
        {
            InitializeComponent();
        }

        private void timerGenerateText_Tick(object sender, EventArgs e)
        {
            if (_r.Next(10) > 5)
                _log.AddToLog("Some event to log.", _randomColors[_r.Next(3)]);
        }

        private void timeUpdateLogWindow_Tick(object sender, EventArgs e)
        {
            richTextBox1.Rtf = _log.GetLogAsRichText(true);
            richTextBox1.ScrollToBottom();
        }
    }
}

Ce formulaire est créé avec deux minuteries, une pour générer des entrées de journal de manière pseudo-aléatoire et une pour remplir la RichTextBox elle-même. Dans cet exemple, la classe de journal est instanciée avec 100 lignes de défilement arrière. Les couleurs du contrôle RichTextBox sont définies pour avoir un fond noir avec des avant-plans blancs et de différentes couleurs. Le temporisateur pour générer du texte est à un intervalle de 100 ms tandis que celui pour mettre à jour la fenêtre de journal est à 1000 ms.

Exemple de sortie:

Logger Example Output

C'est loin d'être parfait ou fini, mais voici quelques mises en garde et des choses qui pourraient être ajoutées ou améliorées (dont certaines que j'ai faites dans des projets ultérieurs):

  1. Avec de grandes valeurs pour maximumEntries, les performances sont médiocres. Cette classe de journalisation a été conçue uniquement pour quelques centaines de lignes de défilement arrière.
  2. Le remplacement du texte de RichTextBox peut entraîner un scintillement. Je garde toujours le minuteur de rafraîchissement à un intervalle relativement lent. (Une seconde dans cet exemple.)
  3. En ajoutant au # 2 ci-dessus, certains de mes projets vérifient si le journal contient de nouvelles entrées avant de redessiner le contenu RichTextBox, pour éviter de le rafraîchir inutilement.
  4. L'horodatage de chaque entrée de journal peut être rendu facultatif et autoriser différents formats.
  5. Il n'y a aucun moyen de suspendre le journal dans cet exemple, mais beaucoup de mes projets fournissent un mécanisme pour suspendre le comportement de défilement, pour permettre aux utilisateurs de faire défiler, sélectionner et copier du texte manuellement à partir de la fenêtre du journal.

N'hésitez pas à modifier et à améliorer cet exemple. Vos commentaires sont les bienvenus.

2
JYelton